@app-vitals/agent-admin 0.10.0 → 0.12.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anthropic-ai/sdk": "0.135.0",
46
- "@app-vitals/agent-lib": "0.10.0",
46
+ "@app-vitals/agent-lib": "0.12.0",
47
47
  "@hono/zod-openapi": "^1.0.0",
48
48
  "@prisma/adapter-pg": "^7.10.0",
49
49
  "@prisma/client": "^7.10.0",
@@ -2808,6 +2808,11 @@ export function renderChatThreadPage(
2808
2808
  );
2809
2809
  const signedOutBanner = `<a id="signed-out-banner" class="chat-signed-out-banner" href="/admin/login?returnTo=${signedOutReturnTo}" role="alert" style="display:none;padding:10px 14px;margin-bottom:8px;background:#fef3c7;color:#92400e;border:1px solid #f59e0b;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;text-align:center">You're signed out. Tap to sign in.</a>`;
2810
2810
 
2811
+ // Held-upload prompt (ASX-1.3): hidden until the thread loads with a
2812
+ // voice note / file that failed on a 401 and is waiting in IndexedDB.
2813
+ // Never auto-sent; the user chooses Resend or Discard.
2814
+ const heldUploadPrompt = `<div id="held-upload-prompt" class="chat-held-upload" role="status" style="display:none;padding:10px 14px;margin-bottom:8px;background:#eff6ff;color:#1e3a8a;border:1px solid #93c5fd;border-radius:8px;font-size:14px"><span>You have an unsent voice note.</span> <button type="button" id="held-upload-resend" style="margin-left:8px;padding:4px 10px;border-radius:6px;border:1px solid #1e3a8a;background:#1e3a8a;color:#fff;cursor:pointer">Resend</button> <button type="button" id="held-upload-discard" style="margin-left:4px;padding:4px 10px;border-radius:6px;border:1px solid #93c5fd;background:#fff;color:#1e3a8a;cursor:pointer">Discard</button></div>`;
2815
+
2811
2816
  const renameForm = `
2812
2817
  <form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(threadId)}/rename" style="margin-top:12px">
2813
2818
  <div class="form-row" style="align-items:center;gap:8px">
@@ -3093,6 +3098,76 @@ export function renderChatThreadPage(
3093
3098
  sendBtn.disabled = true;
3094
3099
  if (micBtn) micBtn.disabled = true;
3095
3100
  if (signedOutBanner) signedOutBanner.style.display = 'block';
3101
+ hideHeldPrompt();
3102
+ }
3103
+
3104
+ // Held uploads (ASX-1.3): a voice note / file whose upload hit a 401 is
3105
+ // kept in IndexedDB, keyed by agent+thread (one record per thread, the most
3106
+ // recent wins). Every call is wrapped so a missing or failing IndexedDB
3107
+ // (private mode, quota) silently degrades to the plain signed-out banner.
3108
+ var HELD_DB = 'chat-held-uploads';
3109
+ var HELD_STORE = 'held-uploads';
3110
+ var heldKey = agentId + ':' + threadId;
3111
+ var heldPrompt = document.getElementById('held-upload-prompt');
3112
+ function openHeldDb(cb) {
3113
+ try {
3114
+ if (!window.indexedDB) { cb(null); return; }
3115
+ var req = indexedDB.open(HELD_DB, 1);
3116
+ req.onupgradeneeded = function() {
3117
+ try { req.result.createObjectStore(HELD_STORE); } catch (e) {}
3118
+ };
3119
+ req.onsuccess = function() { cb(req.result); };
3120
+ req.onerror = function() { cb(null); };
3121
+ req.onblocked = function() { cb(null); };
3122
+ } catch (e) { cb(null); }
3123
+ }
3124
+ function heldOp(mode, run, done) {
3125
+ openHeldDb(function(db) {
3126
+ if (!db) { done(null); return; }
3127
+ var result = null;
3128
+ try {
3129
+ var tx = db.transaction(HELD_STORE, mode);
3130
+ var req = run(tx.objectStore(HELD_STORE));
3131
+ if (req) req.onsuccess = function() { result = req.result; };
3132
+ tx.oncomplete = function() { try { db.close(); } catch (e) {} done(result); };
3133
+ tx.onerror = tx.onabort = function() { try { db.close(); } catch (e) {} done(null); };
3134
+ } catch (e) {
3135
+ try { db.close(); } catch (e2) {}
3136
+ done(null);
3137
+ }
3138
+ });
3139
+ }
3140
+ function holdUpload(file, then) {
3141
+ var called = false;
3142
+ function finish() { if (!called) { called = true; if (then) then(); } }
3143
+ try {
3144
+ heldOp('readwrite', function(store) {
3145
+ return store.put({ file: file, name: file.name, type: file.type, savedAt: Date.now() }, heldKey);
3146
+ }, finish);
3147
+ } catch (e) { finish(); }
3148
+ }
3149
+ function loadHeld(cb) {
3150
+ try {
3151
+ heldOp('readonly', function(store) { return store.get(heldKey); }, function(rec) {
3152
+ try { cb(rec && rec.file ? rec : null); } catch (e) {}
3153
+ });
3154
+ } catch (e) { cb(null); }
3155
+ }
3156
+ function deleteHeld(then) {
3157
+ var called = false;
3158
+ function finish() { if (!called) { called = true; if (then) then(); } }
3159
+ try {
3160
+ heldOp('readwrite', function(store) { return store.delete(heldKey); }, finish);
3161
+ } catch (e) { finish(); }
3162
+ }
3163
+ function hideHeldPrompt() {
3164
+ if (heldPrompt) heldPrompt.style.display = 'none';
3165
+ }
3166
+ function showHeldPromptIfAny() {
3167
+ if (signedOut || !heldPrompt) return;
3168
+ loadHeld(function(rec) {
3169
+ if (rec && !signedOut) heldPrompt.style.display = 'block';
3170
+ });
3096
3171
  }
3097
3172
 
3098
3173
  // Cheap probe so an app left open learns it is signed out as soon as the
@@ -3319,7 +3394,8 @@ export function renderChatThreadPage(
3319
3394
  if (r.status === 401) {
3320
3395
  // Keep the unsent text so it survives the sign-in round trip.
3321
3396
  if (text) input.value = text;
3322
- showSignedOut();
3397
+ if (file) holdUpload(file, showSignedOut);
3398
+ else showSignedOut();
3323
3399
  return;
3324
3400
  }
3325
3401
  if (!r.ok) {
@@ -3367,6 +3443,24 @@ export function renderChatThreadPage(
3367
3443
  });
3368
3444
 
3369
3445
  input.addEventListener('input', function() { saveDraft(input.value.trim()); });
3446
+
3447
+ // Held-upload prompt actions (ASX-1.3). Resend goes through sendText; the
3448
+ // record is deleted first so a repeat 401 simply re-holds it.
3449
+ var heldResendBtn = document.getElementById('held-upload-resend');
3450
+ var heldDiscardBtn = document.getElementById('held-upload-discard');
3451
+ if (heldResendBtn) heldResendBtn.addEventListener('click', function() {
3452
+ if (signedOut) return;
3453
+ loadHeld(function(rec) {
3454
+ hideHeldPrompt();
3455
+ if (!rec) return;
3456
+ deleteHeld(function() { sendText('', rec.file); });
3457
+ });
3458
+ });
3459
+ if (heldDiscardBtn) heldDiscardBtn.addEventListener('click', function() {
3460
+ hideHeldPrompt();
3461
+ deleteHeld();
3462
+ });
3463
+ showHeldPromptIfAny();
3370
3464
  var restoredDraft = loadDraft();
3371
3465
  if (restoredDraft && !input.value) input.value = restoredDraft;
3372
3466
 
@@ -3614,6 +3708,7 @@ export function renderChatThreadPage(
3614
3708
  ${drawerScrim}
3615
3709
  <div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
3616
3710
  ${signedOutBanner}
3711
+ ${heldUploadPrompt}
3617
3712
  <!-- Messages area (scrollable) -->
3618
3713
  <div id="messages-container" class="chat-messages-container">
3619
3714
  ${messageBubbles}
@@ -3115,6 +3115,42 @@ describe("renderChatThreadPage", () => {
3115
3115
  expect(html).toContain("clearDraft()");
3116
3116
  });
3117
3117
  });
3118
+
3119
+ describe("held voice notes (ASX-1.3)", () => {
3120
+ const page = () =>
3121
+ renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
3122
+
3123
+ test("includes the IndexedDB held-upload store keyed by agent+thread", () => {
3124
+ const html = page();
3125
+ expect(html).toContain("indexedDB.open(HELD_DB");
3126
+ expect(html).toContain("var heldKey = agentId + ':' + threadId");
3127
+ expect(html).toContain("function holdUpload(");
3128
+ expect(html).toContain("function loadHeld(");
3129
+ expect(html).toContain("function deleteHeld(");
3130
+ });
3131
+
3132
+ test("holds the file on a 401 before showing the signed-out banner", () => {
3133
+ const html = page();
3134
+ expect(html).toContain("if (file) holdUpload(file, showSignedOut);");
3135
+ });
3136
+
3137
+ test("renders a hidden Resend/Discard prompt that never auto-sends", () => {
3138
+ const html = page();
3139
+ expect(html).toContain('id="held-upload-prompt"');
3140
+ expect(html).toContain("You have an unsent voice note.");
3141
+ expect(html).toContain('id="held-upload-resend"');
3142
+ expect(html).toContain('id="held-upload-discard"');
3143
+ expect(html).toContain("sendText('', rec.file)");
3144
+ // Only the resend click handler sends a held file.
3145
+ expect(html.match(/sendText\('', rec\.file\)/g)?.length).toBe(1);
3146
+ });
3147
+
3148
+ test("every IndexedDB access is guarded so failures are silent", () => {
3149
+ const html = page();
3150
+ expect(html).toContain("if (!window.indexedDB)");
3151
+ expect(html).toContain("req.onerror = function() { cb(null); }");
3152
+ });
3153
+ });
3118
3154
  });
3119
3155
 
3120
3156
  // ─── renderChatThreadPage — inline styles moved to classes (CFB-1.3) ────────