@hienlh/ppm 0.8.49 → 0.8.51

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 (40) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/web/assets/api-settings-D4bgXrLU.js +1 -0
  3. package/dist/web/assets/chat-tab-CoV1KQMy.js +7 -0
  4. package/dist/web/assets/{code-editor-CdiHsvVd.js → code-editor-BZPwIGKR.js} +1 -1
  5. package/dist/web/assets/{database-viewer-DCB3fyHi.js → database-viewer-Bof6ObKy.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-DZEXDwGs.js → diff-viewer-te-ZDE1c.js} +1 -1
  7. package/dist/web/assets/{git-graph-BfgY255b.js → git-graph-D5MCrTdW.js} +1 -1
  8. package/dist/web/assets/index-Bb5A248z.js +37 -0
  9. package/dist/web/assets/index-CoyMn-Mj.css +2 -0
  10. package/dist/web/assets/keybindings-store-CS8iFKWp.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-F1beFJEy.js → markdown-renderer-BVNQfyqo.js} +1 -1
  12. package/dist/web/assets/{postgres-viewer-CxO5FaWj.js → postgres-viewer-DAAoR6eS.js} +1 -1
  13. package/dist/web/assets/{settings-store-xG6mKqkD.js → settings-store-DL2KEbtc.js} +2 -2
  14. package/dist/web/assets/settings-tab-BfXWlmwG.js +1 -0
  15. package/dist/web/assets/{sqlite-viewer-CQNRFUxH.js → sqlite-viewer-C0pY249Q.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-CEvaCyVU.js → terminal-tab-pBZIdGj-.js} +2 -2
  17. package/dist/web/assets/{use-monaco-theme-DlFSiqvG.js → use-monaco-theme-DwP4EHdO.js} +1 -1
  18. package/dist/web/index.html +4 -4
  19. package/dist/web/sw.js +1 -1
  20. package/package.json +1 -1
  21. package/src/providers/claude-agent-sdk.ts +21 -8
  22. package/src/server/index.ts +4 -0
  23. package/src/server/routes/accounts.ts +137 -2
  24. package/src/server/routes/proxy.ts +79 -0
  25. package/src/server/routes/settings.ts +53 -1
  26. package/src/services/proxy.service.ts +139 -0
  27. package/src/types/config.ts +1 -0
  28. package/src/web/components/chat/message-list.tsx +2 -125
  29. package/src/web/components/settings/accounts-settings-section.tsx +255 -6
  30. package/src/web/components/settings/ai-settings-section.tsx +21 -0
  31. package/src/web/components/settings/proxy-settings-section.tsx +217 -0
  32. package/src/web/components/settings/settings-tab.tsx +5 -2
  33. package/src/web/lib/api-settings.ts +52 -0
  34. package/test-tokens.mjs +212 -0
  35. package/dist/web/assets/api-settings-CaKDC7_s.js +0 -1
  36. package/dist/web/assets/chat-tab-CkVy9ut7.js +0 -7
  37. package/dist/web/assets/index-DubLYgN1.css +0 -2
  38. package/dist/web/assets/index-odr3ymlS.js +0 -28
  39. package/dist/web/assets/keybindings-store-tyvdfWMV.js +0 -1
  40. package/dist/web/assets/settings-tab-DLtrBBV2.js +0 -1
@@ -72,71 +72,15 @@ export function MessageList({
72
72
  );
73
73
  }
74
74
 
75
- // Track which user message is pinned (scrolled above viewport) + push-out offset
76
- const [pinnedContent, setPinnedContent] = useState<string | null>(null);
77
- const [pushOffset, setPushOffset] = useState(0);
78
- const wrapperRef = useRef<HTMLDivElement>(null);
79
- const pinnedRef = useRef<HTMLDivElement>(null);
80
-
81
75
  const filtered = useMemo(() => messages.filter((msg) => {
82
76
  const hasContent = msg.content && msg.content.trim().length > 0;
83
77
  const hasEvents = msg.events && msg.events.length > 0;
84
78
  return hasContent || hasEvents;
85
79
  }), [messages]);
86
80
 
87
- // Observe user message elements to track which one is pinned + push-out transition
88
- useEffect(() => {
89
- const wrapper = wrapperRef.current;
90
- if (!wrapper) return;
91
- const scrollEl = wrapper.querySelector("[data-stick-to-bottom-scroll]") as HTMLElement
92
- ?? wrapper.firstElementChild as HTMLElement;
93
- if (!scrollEl || scrollEl.scrollHeight <= scrollEl.clientHeight) return;
94
-
95
- const handleScroll = () => {
96
- const userEls = wrapper.querySelectorAll<HTMLElement>("[data-user-content]");
97
- const scrollRect = scrollEl.getBoundingClientRect();
98
- const pinnedH = pinnedRef.current?.offsetHeight ?? 0;
99
-
100
- let lastAbove: string | null = null;
101
- let nextTop = Infinity;
102
-
103
- for (let i = 0; i < userEls.length; i++) {
104
- const rect = userEls[i]!.getBoundingClientRect();
105
- if (rect.top < scrollRect.top + 4) {
106
- lastAbove = userEls[i]!.getAttribute("data-user-content");
107
- // Find the next user message after this one
108
- const nextEl = userEls[i + 1];
109
- nextTop = nextEl ? nextEl.getBoundingClientRect().top - scrollRect.top : Infinity;
110
- }
111
- }
112
-
113
- setPinnedContent(lastAbove);
114
- // Push-out: when next header enters the pinned area, offset upward
115
- if (pinnedH > 0 && nextTop < pinnedH) {
116
- setPushOffset(nextTop - pinnedH);
117
- } else {
118
- setPushOffset(0);
119
- }
120
- };
121
-
122
- scrollEl.addEventListener("scroll", handleScroll, { passive: true });
123
- handleScroll();
124
- return () => scrollEl.removeEventListener("scroll", handleScroll);
125
- }, [filtered]);
126
-
127
81
  return (
128
82
  <div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
129
- {/* Pinned header — overlays scroll content like react-listview-sticky-header */}
130
- {pinnedContent && (
131
- <div
132
- ref={pinnedRef}
133
- className="absolute top-0 left-0 right-0 z-20 bg-background"
134
- style={pushOffset < 0 ? { transform: `translateY(${pushOffset}px)` } : undefined}
135
- >
136
- <PinnedUserMessage content={pinnedContent} projectName={projectName} />
137
- </div>
138
- )}
139
- <StickToBottom ref={wrapperRef} className="flex-1 overflow-y-auto overflow-x-hidden" resize="smooth" initial="instant">
83
+ <StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden" resize="smooth" initial="instant">
140
84
  <StickToBottom.Content className="p-4 space-y-4">
141
85
  {filtered.map((msg) => (
142
86
  <MessageBubble
@@ -162,73 +106,6 @@ export function MessageList({
162
106
  );
163
107
  }
164
108
 
165
- /** Compact pinned bar showing the current user message at the top of chat */
166
- function PinnedUserMessage({ content, projectName }: { content: string; projectName?: string }) {
167
- const { files, text } = useMemo(() => {
168
- const parsed = parseUserAttachments(content);
169
- const { cleanText } = extractSystemTags(parsed.text);
170
- return { files: parsed.files, text: cleanText };
171
- }, [content]);
172
-
173
- const [expanded, setExpanded] = useState(false);
174
- const [isOverflowing, setIsOverflowing] = useState(false);
175
- const contentRef = useRef<HTMLDivElement>(null);
176
-
177
- // Reset expanded state when pinned message changes
178
- useEffect(() => { setExpanded(false); }, [content]);
179
-
180
- useEffect(() => {
181
- const el = contentRef.current;
182
- if (!el) return;
183
- const check = () => setIsOverflowing(el.scrollHeight > el.clientHeight + 2);
184
- check();
185
- const ro = new ResizeObserver(check);
186
- ro.observe(el);
187
- return () => ro.disconnect();
188
- }, [text]);
189
-
190
- if (!text && files.length === 0) return null;
191
-
192
- return (
193
- <div className="shrink-0 px-4 pt-3 pb-2">
194
- <div className="rounded-lg bg-primary/10 px-3 py-2 text-sm text-text-primary space-y-2 border border-primary/15 shadow-sm">
195
- {files.length > 0 && (
196
- <div className="flex flex-wrap gap-1.5">
197
- {files.map((filePath, i) => (
198
- <div key={i} 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">
199
- {isImagePath(filePath) ? <ImageIcon className="size-3 shrink-0" /> : <FileText className="size-3 shrink-0" />}
200
- <span className="truncate max-w-32">{basename(filePath)}</span>
201
- </div>
202
- ))}
203
- </div>
204
- )}
205
- {text && (
206
- <div>
207
- <div
208
- ref={contentRef}
209
- className={cn(
210
- "whitespace-pre-wrap break-words transition-all duration-200",
211
- !expanded && "line-clamp-2",
212
- expanded && "max-h-[40vh] overflow-y-auto",
213
- )}
214
- >
215
- {text}
216
- </div>
217
- {(isOverflowing || expanded) && (
218
- <button
219
- onClick={() => setExpanded(!expanded)}
220
- className="flex items-center gap-1 text-xs text-primary/70 hover:text-primary mt-1 transition-colors"
221
- >
222
- {expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
223
- </button>
224
- )}
225
- </div>
226
- )}
227
- </div>
228
- </div>
229
- );
230
- }
231
-
232
109
  /** Floating button to scroll back to bottom when user has scrolled up */
233
110
  function ScrollToBottomButton() {
234
111
  const { isAtBottom, scrollToBottom } = useStickToBottomContext();
@@ -374,7 +251,7 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
374
251
  }, [text]);
375
252
 
376
253
  return (
377
- <div data-user-content={content} className="group/user relative rounded-lg bg-primary/10 px-3 py-2 text-sm text-text-primary border border-primary/15 shadow-sm">
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">
378
255
  {/* System tags as badges */}
379
256
  {tags.length > 0 && <SystemTagBadges tags={tags} />}
380
257
 
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
6
6
  import { Label } from "@/components/ui/label";
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
8
8
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
9
- import { Eye, Loader2, Copy, X, Download, Upload, Lock } from "lucide-react";
9
+ import { Eye, Loader2, Copy, X, Download, Upload, Lock, FlaskConical } from "lucide-react";
10
10
  import { getAuthToken } from "../../lib/api-client";
11
11
  import {
12
12
  getAccounts,
@@ -20,10 +20,15 @@ import {
20
20
  updateAccountSettings,
21
21
  getAllAccountUsages,
22
22
  importAccounts,
23
+ testAccountToken,
24
+ testExport,
25
+ testRawToken,
23
26
  type AccountInfo,
24
27
  type AccountSettings,
25
28
  type AccountUsageEntry,
26
29
  type OAuthProfileData,
30
+ type TokenTestResult,
31
+ type ExportedTokenInfo,
27
32
  } from "../../lib/api-settings";
28
33
 
29
34
  function detectTokenType(token: string): string {
@@ -152,6 +157,7 @@ export function AccountsSettingsSection() {
152
157
  const [exportSelected, setExportSelected] = useState<Set<string>>(new Set());
153
158
  const [exporting, setExporting] = useState(false);
154
159
  const [exportFullTransfer, setExportFullTransfer] = useState(false);
160
+ const [exportRefreshBefore, setExportRefreshBefore] = useState(false);
155
161
 
156
162
  // Import dialog
157
163
  const [showImportDialog, setShowImportDialog] = useState(false);
@@ -160,6 +166,16 @@ export function AccountsSettingsSection() {
160
166
  const [importing, setImporting] = useState(false);
161
167
  const [importError, setImportError] = useState<string | null>(null);
162
168
 
169
+ // Token test dialog
170
+ const [showTokenTest, setShowTokenTest] = useState(false);
171
+ const [tokenTestResults, setTokenTestResults] = useState<Map<string, { loading: boolean; result?: TokenTestResult; error?: string }>>(new Map());
172
+ const [tokenTestRefresh, setTokenTestRefresh] = useState(false);
173
+ // Export simulation — accumulate rounds
174
+ const [exportRounds, setExportRounds] = useState<{ round: number; time: string; includeRefresh: boolean; items: ExportedTokenInfo[] }[]>([]);
175
+ const [exportSimLoading, setExportSimLoading] = useState(false);
176
+ const [exportSimIncludeRefresh, setExportSimIncludeRefresh] = useState(false);
177
+ const [rawTokenTests, setRawTokenTests] = useState<Map<string, { loading: boolean; status?: string; code?: number; error?: string }>>(new Map());
178
+
163
179
  useEffect(() => {
164
180
  refresh();
165
181
  }, []);
@@ -304,6 +320,7 @@ export function AccountsSettingsSection() {
304
320
  setExportConfirm("");
305
321
  setExportSelected(new Set(exportableAccounts.map((a) => a.id)));
306
322
  setExportFullTransfer(false);
323
+ setExportRefreshBefore(false);
307
324
  setShowExportDialog(true);
308
325
  }
309
326
 
@@ -325,7 +342,7 @@ export function AccountsSettingsSection() {
325
342
  const res = await fetch("/api/accounts/export", {
326
343
  method: "POST",
327
344
  headers,
328
- body: JSON.stringify({ password: exportPassword, accountIds: [...exportSelected], includeRefreshToken: exportFullTransfer }),
345
+ body: JSON.stringify({ password: exportPassword, accountIds: [...exportSelected], includeRefreshToken: exportFullTransfer, refreshBeforeExport: exportRefreshBefore }),
329
346
  });
330
347
  if (!res.ok) { const j = await res.json() as any; throw new Error(j.error ?? `Export failed: ${res.status}`); }
331
348
  const text = await res.text();
@@ -375,6 +392,46 @@ export function AccountsSettingsSection() {
375
392
  }
376
393
 
377
394
 
395
+ async function simulateExport() {
396
+ const oauthIds = accounts.filter((a) => a.hasRefreshToken).map((a) => a.id);
397
+ if (!oauthIds.length) return;
398
+ setExportSimLoading(true);
399
+ try {
400
+ const data = await testExport(oauthIds, exportSimIncludeRefresh);
401
+ const roundNum = exportRounds.length + 1;
402
+ const time = new Date().toLocaleTimeString();
403
+ setExportRounds((prev) => [...prev, { round: roundNum, time, includeRefresh: exportSimIncludeRefresh, items: data }]);
404
+ } catch { /* ignore */ }
405
+ setExportSimLoading(false);
406
+ }
407
+
408
+ async function testRawTokenClick(key: string, token: string) {
409
+ setRawTokenTests((prev) => new Map(prev).set(key, { loading: true }));
410
+ try {
411
+ const result = await testRawToken(token);
412
+ setRawTokenTests((prev) => new Map(prev).set(key, { loading: false, ...result }));
413
+ } catch (e) {
414
+ setRawTokenTests((prev) => new Map(prev).set(key, { loading: false, status: "error", error: (e as Error).message }));
415
+ }
416
+ }
417
+
418
+ async function runTokenTest(id: string) {
419
+ setTokenTestResults((prev) => new Map(prev).set(id, { loading: true }));
420
+ try {
421
+ const result = await testAccountToken(id, tokenTestRefresh);
422
+ setTokenTestResults((prev) => new Map(prev).set(id, { loading: false, result }));
423
+ } catch (e) {
424
+ setTokenTestResults((prev) => new Map(prev).set(id, { loading: false, error: (e as Error).message }));
425
+ }
426
+ }
427
+
428
+ async function runAllTokenTests() {
429
+ const oauthAccounts = accounts.filter((a) => a.expiresAt !== null);
430
+ for (const acc of oauthAccounts) {
431
+ runTokenTest(acc.id);
432
+ }
433
+ }
434
+
378
435
  const tokenHint = newToken.trim() ? detectTokenType(newToken.trim()) : "";
379
436
 
380
437
  return (
@@ -442,7 +499,7 @@ export function AccountsSettingsSection() {
442
499
  })}
443
500
  </div>
444
501
 
445
- <div className="grid grid-cols-3 gap-1.5">
502
+ <div className={`grid gap-1.5 ${import.meta.env.DEV ? "grid-cols-4" : "grid-cols-3"}`}>
446
503
  <Button size="sm" className="h-8 text-xs cursor-pointer" onClick={() => setShowAddDialog(true)}>
447
504
  + Add
448
505
  </Button>
@@ -452,6 +509,11 @@ export function AccountsSettingsSection() {
452
509
  <Button size="sm" variant="outline" className="h-8 text-xs cursor-pointer" onClick={() => openImportDialog()}>
453
510
  <Upload className="size-3.5 mr-1" /> Import
454
511
  </Button>
512
+ {import.meta.env.DEV && (
513
+ <Button size="sm" variant="outline" className="h-8 text-xs cursor-pointer" onClick={() => { setTokenTestResults(new Map()); setTokenTestRefresh(false); setExportRounds([]); setRawTokenTests(new Map()); setExportSimIncludeRefresh(false); setShowTokenTest(true); }}>
514
+ <FlaskConical className="size-3.5 mr-1" /> Test
515
+ </Button>
516
+ )}
455
517
  </div>
456
518
  </div>
457
519
 
@@ -715,6 +777,19 @@ export function AccountsSettingsSection() {
715
777
  Include refresh tokens (full transfer)
716
778
  </label>
717
779
  </div>
780
+ {/* Refresh before export toggle */}
781
+ <div className="flex items-center gap-2">
782
+ <input
783
+ type="checkbox"
784
+ id="export-refresh-before"
785
+ checked={exportRefreshBefore}
786
+ onChange={(e) => setExportRefreshBefore(e.target.checked)}
787
+ className="size-3.5 accent-primary cursor-pointer"
788
+ />
789
+ <label htmlFor="export-refresh-before" className="text-[11px] cursor-pointer">
790
+ Refresh tokens before export
791
+ </label>
792
+ </div>
718
793
  {exportFullTransfer ? (
719
794
  <div className="rounded-md border border-red-500/30 bg-red-500/5 p-2.5 space-y-1">
720
795
  <p className="text-[10px] font-medium text-red-600">Full transfer — source accounts will expire</p>
@@ -722,11 +797,18 @@ export function AccountsSettingsSection() {
722
797
  Refresh tokens are included. Once the target machine refreshes, <strong>accounts on this machine will only work for ~1h</strong> then become temporary. Use this only to move accounts to another machine.
723
798
  </p>
724
799
  </div>
725
- ) : (
800
+ ) : exportRefreshBefore ? (
726
801
  <div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-2.5 space-y-1">
727
- <p className="text-[10px] font-medium text-amber-600">Temporary access only (default)</p>
802
+ <p className="text-[10px] font-medium text-amber-600">Refresh before export — invalidates previous shares</p>
803
+ <p className="text-[10px] text-muted-foreground leading-relaxed">
804
+ Tokens will be refreshed to maximize validity (~1h). But <strong>any previously shared tokens will be invalidated</strong> because Anthropic only allows 1 active access token per account.
805
+ </p>
806
+ </div>
807
+ ) : (
808
+ <div className="rounded-md border border-green-500/30 bg-green-500/5 p-2.5 space-y-1">
809
+ <p className="text-[10px] font-medium text-green-600">Share current token (default, safe)</p>
728
810
  <p className="text-[10px] text-muted-foreground leading-relaxed">
729
- Only access tokens exported (~1h validity). Source machine is not affected. Target must login directly for permanent access.
811
+ Exports the current access token as-is. Host and target share the same token. No invalidation. Token validity = remaining time until next auto-refresh.
730
812
  </p>
731
813
  </div>
732
814
  )}
@@ -776,6 +858,173 @@ export function AccountsSettingsSection() {
776
858
  </DialogContent>
777
859
  </Dialog>
778
860
 
861
+ {/* Token test dialog */}
862
+ <Dialog open={showTokenTest} onOpenChange={(v) => { if (!v) setShowTokenTest(false); }}>
863
+ <DialogContent className="sm:max-w-xl max-h-[85vh] flex flex-col">
864
+ <DialogHeader>
865
+ <DialogTitle className="text-sm flex items-center gap-1.5"><FlaskConical className="size-3.5" /> Token Test</DialogTitle>
866
+ <DialogDescription className="text-xs">Test tokens & simulate export to compare token validity.</DialogDescription>
867
+ </DialogHeader>
868
+ <div className="space-y-3 overflow-y-auto flex-1 pr-1">
869
+ {/* Section 1: Quick test current DB tokens */}
870
+ <div className="space-y-2">
871
+ <div className="flex items-center justify-between">
872
+ <p className="text-[11px] font-medium">Current Tokens (DB)</p>
873
+ <Button size="sm" variant="outline" className="h-6 text-[10px] cursor-pointer" onClick={runAllTokenTests}>
874
+ Test All
875
+ </Button>
876
+ </div>
877
+ <div className="space-y-1.5">
878
+ {accounts.map((acc) => {
879
+ const entry = tokenTestResults.get(acc.id);
880
+ return (
881
+ <div key={acc.id} className="p-2 rounded border bg-card space-y-1">
882
+ <div className="flex items-center justify-between gap-2">
883
+ <div className="min-w-0 flex-1">
884
+ <span className="text-[11px] font-medium truncate block">{acc.label ?? acc.email ?? acc.id.slice(0, 8)}</span>
885
+ {acc.expiresAt && (
886
+ <span className="text-[10px] text-muted-foreground">
887
+ {acc.expiresAt > Math.floor(Date.now() / 1000)
888
+ ? `${Math.floor((acc.expiresAt - Math.floor(Date.now() / 1000)) / 60)}m left`
889
+ : `expired ${Math.floor((Math.floor(Date.now() / 1000) - acc.expiresAt) / 60)}m ago`}
890
+ </span>
891
+ )}
892
+ </div>
893
+ <Button size="sm" variant="outline" className="h-6 text-[10px] cursor-pointer shrink-0" disabled={entry?.loading} onClick={() => runTokenTest(acc.id)}>
894
+ {entry?.loading ? <Loader2 className="size-3 animate-spin" /> : "Test"}
895
+ </Button>
896
+ </div>
897
+ {entry && !entry.loading && (
898
+ <div className="text-[10px] pl-1 border-l-2 border-muted ml-1">
899
+ {entry.error && <p className="text-red-500">{entry.error}</p>}
900
+ {entry.result && (
901
+ <span className={entry.result.accessToken.status.startsWith("valid") ? "text-green-600" : "text-red-500"}>
902
+ {entry.result.accessToken.status} {entry.result.accessToken.code && `(${entry.result.accessToken.code})`}
903
+ </span>
904
+ )}
905
+ </div>
906
+ )}
907
+ </div>
908
+ );
909
+ })}
910
+ </div>
911
+ </div>
912
+
913
+ {/* Section 2: Export simulation */}
914
+ <div className="border-t pt-3 space-y-2">
915
+ <div className="flex items-center justify-between">
916
+ <p className="text-[11px] font-medium">Simulate Export</p>
917
+ {exportRounds.length > 0 && (
918
+ <span className="text-[10px] text-muted-foreground">{exportRounds.length} round(s)</span>
919
+ )}
920
+ </div>
921
+ <p className="text-[10px] text-muted-foreground">
922
+ Each "Run Export" appends a new round. All tokens from every round are kept for comparison.
923
+ </p>
924
+ <div className="flex items-center gap-3">
925
+ <div className="flex items-center gap-1.5">
926
+ <input
927
+ type="checkbox"
928
+ id="sim-include-refresh"
929
+ checked={exportSimIncludeRefresh}
930
+ onChange={(e) => setExportSimIncludeRefresh(e.target.checked)}
931
+ className="size-3 accent-primary cursor-pointer"
932
+ />
933
+ <label htmlFor="sim-include-refresh" className="text-[10px] cursor-pointer">Include refresh tokens</label>
934
+ </div>
935
+ <Button size="sm" className="h-7 text-[11px] cursor-pointer" disabled={exportSimLoading} onClick={simulateExport}>
936
+ {exportSimLoading ? <><Loader2 className="size-3 animate-spin mr-1" /> Exporting...</> : `Run Export #${exportRounds.length + 1}`}
937
+ </Button>
938
+ </div>
939
+
940
+ {exportRounds.length > 0 && (
941
+ <div className="space-y-3">
942
+ {/* Test All button across all rounds */}
943
+ <Button
944
+ size="sm"
945
+ variant="outline"
946
+ className="w-full h-7 text-[10px] cursor-pointer"
947
+ onClick={() => {
948
+ for (const round of exportRounds) {
949
+ for (const item of round.items) {
950
+ if (item.preExportTokenFull) testRawTokenClick(`r${round.round}-pre-${item.id}`, item.preExportTokenFull);
951
+ if (item.exportedTokenFull) testRawTokenClick(`r${round.round}-exp-${item.id}`, item.exportedTokenFull);
952
+ if (item.postExportTokenFull) testRawTokenClick(`r${round.round}-post-${item.id}`, item.postExportTokenFull);
953
+ }
954
+ }
955
+ }}
956
+ >
957
+ Test All Tokens ({exportRounds.reduce((n, r) => n + r.items.length * 3, 0)} tokens)
958
+ </Button>
959
+
960
+ {/* Render each round */}
961
+ {exportRounds.map((round) => (
962
+ <div key={round.round} className="space-y-1.5">
963
+ <div className="flex items-center gap-2">
964
+ <p className="text-[11px] font-medium text-primary">Round #{round.round}</p>
965
+ <span className="text-[9px] text-muted-foreground">{round.time}</span>
966
+ <Badge variant={round.includeRefresh ? "destructive" : "secondary"} className="text-[8px] px-1 py-0">
967
+ {round.includeRefresh ? "with refresh" : "access only"}
968
+ </Badge>
969
+ </div>
970
+ {round.items.map((item) => (
971
+ <div key={`r${round.round}-${item.id}`} className="p-2 rounded-lg border bg-card space-y-1.5">
972
+ <div className="flex items-center gap-2">
973
+ <span className="text-[10px] font-medium">{item.label ?? item.email ?? item.id.slice(0, 8)}</span>
974
+ {item.tokenChanged && <Badge variant="secondary" className="text-[8px] px-1 py-0">DB token changed</Badge>}
975
+ </div>
976
+ {[
977
+ { key: `r${round.round}-pre-${item.id}`, label: "Pre-export", token: item.preExportTokenFull, preview: item.preExportToken, expires: item.preExportExpires },
978
+ { key: `r${round.round}-exp-${item.id}`, label: "Exported", token: item.exportedTokenFull, preview: item.exportedToken, expires: item.exportedExpires },
979
+ { key: `r${round.round}-post-${item.id}`, label: "Post-export", token: item.postExportTokenFull, preview: item.postExportToken, expires: item.postExportExpires },
980
+ ].map((row) => {
981
+ const test = rawTokenTests.get(row.key);
982
+ return (
983
+ <div key={row.key} className="flex items-center gap-1.5 text-[10px]">
984
+ <span className="w-20 shrink-0 text-muted-foreground text-[9px]">{row.label}</span>
985
+ <code className="flex-1 truncate font-mono text-[9px] bg-muted px-1 rounded">{row.preview ?? "N/A"}</code>
986
+ {row.expires && (
987
+ <span className="text-[9px] text-muted-foreground shrink-0">
988
+ {row.expires > Math.floor(Date.now() / 1000)
989
+ ? `${Math.floor((row.expires - Math.floor(Date.now() / 1000)) / 60)}m`
990
+ : `exp`}
991
+ </span>
992
+ )}
993
+ {row.token ? (
994
+ <Button
995
+ size="sm"
996
+ variant="outline"
997
+ className="h-5 text-[9px] px-1.5 cursor-pointer shrink-0"
998
+ disabled={test?.loading}
999
+ onClick={() => testRawTokenClick(row.key, row.token!)}
1000
+ >
1001
+ {test?.loading ? <Loader2 className="size-2.5 animate-spin" /> : "Test"}
1002
+ </Button>
1003
+ ) : <span className="text-[9px] text-muted-foreground">-</span>}
1004
+ {test && !test.loading && (
1005
+ <span className={`text-[9px] font-medium shrink-0 ${test.status?.startsWith("valid") ? "text-green-600" : "text-red-500"}`}>
1006
+ {test.status}
1007
+ </span>
1008
+ )}
1009
+ </div>
1010
+ );
1011
+ })}
1012
+ </div>
1013
+ ))}
1014
+ </div>
1015
+ ))}
1016
+ </div>
1017
+ )}
1018
+ </div>
1019
+ </div>
1020
+ <DialogFooter>
1021
+ <Button size="sm" variant="outline" className="text-xs h-7 cursor-pointer" onClick={() => setShowTokenTest(false)}>
1022
+ Close
1023
+ </Button>
1024
+ </DialogFooter>
1025
+ </DialogContent>
1026
+ </Dialog>
1027
+
779
1028
  {/* Import dialog — paste/file data + password */}
780
1029
  <Dialog open={showImportDialog} onOpenChange={(v) => { if (!v) setShowImportDialog(false); }}>
781
1030
  <DialogContent className="sm:max-w-md">
@@ -117,6 +117,27 @@ export function AISettingsSection({ compact }: { compact?: boolean } = {}) {
117
117
  />
118
118
  </div>
119
119
 
120
+ <div className={fieldGap}>
121
+ <Label htmlFor="ai-api-key" className={compact ? labelSize : undefined}>API Key / Token</Label>
122
+ <Input
123
+ key={`apikey-${revision}`}
124
+ id="ai-api-key"
125
+ type="password"
126
+ defaultValue={config?.api_key ?? ""}
127
+ placeholder="sk-ant-... (optional, overrides accounts)"
128
+ className={compact ? "h-7 text-[11px] font-mono" : "font-mono"}
129
+ onBlur={(e) => {
130
+ const val = e.target.value.trim();
131
+ // Don't save if it's the masked value
132
+ if (val.startsWith("••••")) return;
133
+ handleSave("api_key", val || undefined);
134
+ }}
135
+ />
136
+ <p className={`${compact ? "text-[9px]" : "text-[11px]"} text-muted-foreground`}>
137
+ Direct API key or OAuth token. Leave empty to use connected accounts.
138
+ </p>
139
+ </div>
140
+
120
141
  <div className={fieldGap}>
121
142
  <Label htmlFor="ai-effort" className={compact ? labelSize : undefined}>Effort</Label>
122
143
  <Select