bunnyquery 1.6.2 → 1.7.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/dist/engine.cjs CHANGED
@@ -290,7 +290,7 @@ File attachments: When a user message contains an "Attached files:" section with
290
290
  - Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
291
291
  - Most attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
292
292
  - For any file given to you as a URL instead of inline content (e.g. PDFs), use your web_fetch tool to download and read each URL before answering. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
293
- File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix \u2014 do NOT show it. Format: [filename](path/to/file) for storage paths, or [filename](https://...) for external URLs. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand \u2014 so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
293
+ File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix \u2014 do NOT show it. Format: [filename](db:path/to/file) for storage paths, or [filename](https://...) for external URLs. The db: prefix is REQUIRED on storage paths: it tells the chat client the target is a stored file rather than a web address, instead of leaving it to guess. Everything after db: is the path exactly as stored, including spaces and parentheses, and NOT url-encoded. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand \u2014 so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
294
294
  File lookup: When the user asks to see, list, or show files (e.g. "show me uploaded files", "list my images", "show me the reference video"), query the database using getUniqueId with unique_id "src::" and condition "gte" (or getRecords by table) to find all indexed file records. Present each result as a markdown link as described above. Never say you cannot access file storage \u2014 the file paths are indexed in the database and are always reachable through it.
295
295
  File generation: When the user asks you to generate a file \u2014 or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown \u2014 put the file's full contents inside a fenced code block whose info string is the intended filename WITH its extension (e.g. report.csv), NOT a language name like "csv". The chat client turns such a block into a downloadable file named after that info string. Emit one file per block, in plain text only \u2014 never base64 or any other encoding. Example for CSV:
296
296
  \`\`\`filename.csv
@@ -415,6 +415,8 @@ ${placeholder}
415
415
 
416
416
  DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW for tabular data (keyed by the column headers), or one record per section for prose. Capture every value you can read. Use the storage path above for the "src::" unique_id on the file-level record, and link every row/section record to it by reference.
417
417
 
418
+ If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows into the record for the row it is anchored to (a \xABPHOTO A88\xBB marker in the grid text only says WHERE a picture sits - the picture itself is attached to this message). Never report that photo contents could not be extracted when images are attached here.
419
+
418
420
  Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to you automatically. Report only what you were actually shown, and never imply you have seen the whole file when the note beside the window says more remains.`;
419
421
  }
420
422
  function buildIndexingContinueMessage(attachment) {
@@ -525,8 +527,10 @@ function isAuthExpiredError(input) {
525
527
  var EXPIRED_ATTACHMENT_URL_HOST = "_expired_.url";
526
528
  var EXPIRED_ATTACHMENT_URL_ORIGIN = "https://" + EXPIRED_ATTACHMENT_URL_HOST;
527
529
  var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
530
+ var EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = 20 * 60;
531
+ var LINK_REFRESH_WINDOW_MS = (EXPIRED_LINK_REFRESH_EXPIRES_SECONDS - 5 * 60) * 1e3;
528
532
  function createInlineLinkRegex() {
529
- return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]+|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]+|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
533
+ return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
530
534
  }
531
535
  function safeDecodeURIComponent(v) {
532
536
  try {
@@ -586,18 +590,150 @@ function isServiceDbAttachmentHref(href, serviceId) {
586
590
  return false;
587
591
  }
588
592
  }
593
+ function readExpiredAttachmentHref(href) {
594
+ if (!href) return null;
595
+ try {
596
+ var parsed = new URL(href);
597
+ if (parsed.hostname !== EXPIRED_ATTACHMENT_URL_HOST) return null;
598
+ return normalizeAttachmentPathCandidate(parsed.pathname || "") || null;
599
+ } catch (e) {
600
+ return null;
601
+ }
602
+ }
589
603
  function sanitizeAttachmentLinksForHistory(content, serviceId, forAssistant) {
590
604
  if (!content) return content;
591
605
  if (!forAssistant && content.indexOf("Attached files:") === -1) return content;
592
606
  return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function(_m, label, href) {
593
- if (forAssistant && !isServiceDbAttachmentHref(href, serviceId)) return _m;
607
+ if (!isServiceDbAttachmentHref(href, serviceId)) return _m;
594
608
  var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
595
- var labelPath = normalizeAttachmentPathCandidate(label);
596
- var fullPath = remotePath || labelPath;
597
- if (!fullPath) return forAssistant ? _m : "[" + label + "](" + EXPIRED_ATTACHMENT_URL_ORIGIN + "/file)";
609
+ var fullPath = remotePath || normalizeAttachmentPathCandidate(label);
610
+ if (!fullPath) return _m;
598
611
  return "[" + label + "](" + buildDisplayExpiredAttachmentHref(fullPath, label) + ")";
599
612
  });
600
613
  }
614
+ function isHttpUrlLike(target) {
615
+ return /^https?:\/\//i.test((target || "").trim());
616
+ }
617
+ function repairUrlWhitespace(href) {
618
+ if (!href || !/\s/.test(href)) return href;
619
+ var stripped = href.replace(/\s+/g, "");
620
+ if (/^https?:\/\/[^/\s]+\/download\/[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)?$/i.test(stripped)) return stripped;
621
+ return href.trim().replace(/\s/g, "%20");
622
+ }
623
+ function normalizeTrailingInlineToken(value) {
624
+ if (!value) return value;
625
+ var out = value.replace(/[.,;:!?]+$/, "");
626
+ var trimUnmatched = function(openCh, closeCh) {
627
+ while (out.charAt(out.length - 1) === closeCh) {
628
+ var openCount = (out.match(new RegExp("\\" + openCh, "g")) || []).length;
629
+ var closeCount = (out.match(new RegExp("\\" + closeCh, "g")) || []).length;
630
+ if (closeCount > openCount) out = out.slice(0, -1);
631
+ else break;
632
+ }
633
+ };
634
+ trimUnmatched("(", ")");
635
+ trimUnmatched("[", "]");
636
+ trimUnmatched("{", "}");
637
+ out = out.replace(/[`'"*>]+$/, "");
638
+ return out;
639
+ }
640
+ function classifyInlineLink(full, groups, ctx) {
641
+ var g1 = groups[0], g2 = groups[1], g3 = groups[2], g4 = groups[3], g5 = groups[4], g6 = groups[5];
642
+ var dbHostPrefix = (ctx.dbHostPrefix || "").toLowerCase();
643
+ var fresh = function(expiredHref) {
644
+ return ctx.resolveFreshHref ? ctx.resolveFreshHref(expiredHref) : void 0;
645
+ };
646
+ var isDbHost = function(url) {
647
+ return !!dbHostPrefix && url.toLowerCase().indexOf(dbHostPrefix) === 0;
648
+ };
649
+ var asStoredFile = function(remotePath2, label) {
650
+ if (!remotePath2) return null;
651
+ var expiredHref = buildDisplayExpiredAttachmentHref(remotePath2, label);
652
+ var cached = fresh(expiredHref);
653
+ return {
654
+ part: {
655
+ type: "link",
656
+ label: truncateLabelForDisplay(label),
657
+ fullLabel: label,
658
+ href: cached || expiredHref,
659
+ expired: !cached,
660
+ expiredHref,
661
+ remotePath: remotePath2
662
+ }
663
+ };
664
+ };
665
+ if (g1) {
666
+ var rawPath = normalizeTrailingInlineToken(g1);
667
+ var tail = full.slice(("src::" + rawPath).length);
668
+ var srcIsUrl = isHttpUrlLike(rawPath);
669
+ if (srcIsUrl && !isDbHost(rawPath) && !readExpiredAttachmentHref(rawPath)) {
670
+ return {
671
+ part: { type: "link", label: truncateLabelForDisplay(rawPath), fullLabel: rawPath, href: rawPath, expired: false },
672
+ tail
673
+ };
674
+ }
675
+ var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.serviceId) || normalizeAttachmentPathCandidate(rawPath) : normalizeAttachmentPathCandidate(rawPath));
676
+ var srcBuilt = asStoredFile(srcPath, srcPath);
677
+ return srcBuilt ? { part: srcBuilt.part, tail } : null;
678
+ }
679
+ if (g4 && g5) {
680
+ var dbTarget = /^db:(.+)$/i.exec(g5.trim());
681
+ if (dbTarget) {
682
+ var declared = asStoredFile(normalizeAttachmentPathCandidate(dbTarget[1]), g4);
683
+ if (!declared) return null;
684
+ declared.part.label = truncateLabelForDisplay(g4);
685
+ declared.part.fullLabel = g4;
686
+ return declared;
687
+ }
688
+ if (isHttpUrlLike(g5)) {
689
+ return classifyInlineLink(full, [void 0, g4, repairUrlWhitespace(g5), void 0, void 0, void 0], ctx);
690
+ }
691
+ var trimmedTarget = g5.trim();
692
+ if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedTarget) || trimmedTarget.charAt(0) === "#") {
693
+ return {
694
+ part: { type: "link", label: truncateLabelForDisplay(g4), fullLabel: g4, href: trimmedTarget, expired: false }
695
+ };
696
+ }
697
+ var built = asStoredFile(normalizeAttachmentPathCandidate(g5), g4);
698
+ if (!built) return null;
699
+ built.part.label = truncateLabelForDisplay(g4);
700
+ built.part.fullLabel = g4;
701
+ return built;
702
+ }
703
+ var originalHref = g3 || g6 || "";
704
+ if (!originalHref) return null;
705
+ var urlTail;
706
+ if (!g3 && g6) {
707
+ var trimmedUrl = normalizeTrailingInlineToken(originalHref);
708
+ if (trimmedUrl !== originalHref) urlTail = originalHref.slice(trimmedUrl.length);
709
+ originalHref = trimmedUrl;
710
+ }
711
+ var withTail = function(r) {
712
+ return urlTail ? { part: r.part, tail: urlTail } : r;
713
+ };
714
+ var urlLabel = g2 || originalHref;
715
+ var carried = readExpiredAttachmentHref(originalHref);
716
+ if (carried) {
717
+ var carriedBuilt = asStoredFile(carried, g2 || carried);
718
+ if (carriedBuilt) {
719
+ if (g2) {
720
+ carriedBuilt.part.label = truncateLabelForDisplay(g2);
721
+ carriedBuilt.part.fullLabel = g2;
722
+ }
723
+ return withTail(carriedBuilt);
724
+ }
725
+ }
726
+ if (isServiceDbAttachmentHref(originalHref, ctx.serviceId)) {
727
+ var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.serviceId);
728
+ if (remotePath) {
729
+ var dbBuilt = asStoredFile(remotePath, getExpiredAttachmentVisiblePath(remotePath, urlLabel));
730
+ if (dbBuilt) return withTail(dbBuilt);
731
+ }
732
+ }
733
+ return withTail({
734
+ part: { type: "link", label: truncateLabelForDisplay(urlLabel), fullLabel: urlLabel, href: originalHref, expired: false }
735
+ });
736
+ }
601
737
  function truncateLabelForDisplay(label) {
602
738
  if (!label) return label;
603
739
  if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
@@ -683,7 +819,7 @@ var DEFAULT_OPENAI_IMAGE_DETAIL = "auto";
683
819
  var OPENAI_WEB_SEARCH_EXTERNAL_WEB_ACCESS = true;
684
820
  var MCP_NAME = "BunnyQuery";
685
821
  var DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
686
- var DEFAULT_OPENAI_MODEL = "gpt-5.4";
822
+ var DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
687
823
  var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
688
824
  var clientSecretRequest = (opts) => chatEngineConfig().clientSecretRequest(opts);
689
825
  var getOpenAIImageDetail = (model) => {
@@ -1229,18 +1365,29 @@ function mapHistoryListToMessages(list, platform, opts) {
1229
1365
  var serverItemId = item && typeof item.id === "string" && item.id ? item.id : void 0;
1230
1366
  if (userText) {
1231
1367
  var displayContent;
1368
+ var indexFile = void 0;
1232
1369
  if (item._isBgTask) {
1233
1370
  var nameMatch = userText.match(/^- name: (.+)$/m);
1234
1371
  if (nameMatch) {
1235
1372
  var mimeMatch = userText.match(/^- mime type: (.+)$/m);
1236
1373
  var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
1237
1374
  var pathMatch = userText.match(/^- storage path: (.+)$/m);
1375
+ var isContinuePass = userText.indexOf("CONTINUE indexing") === 0;
1238
1376
  displayContent = opts.formatIndexingLabel(
1239
1377
  nameMatch[1].trim(),
1240
1378
  mimeMatch ? mimeMatch[1].trim() : "",
1241
1379
  sizeMatch ? Number(sizeMatch[1]) : null,
1242
- pathMatch ? pathMatch[1].trim() : void 0
1380
+ pathMatch ? pathMatch[1].trim() : void 0,
1381
+ false,
1382
+ isContinuePass
1243
1383
  );
1384
+ indexFile = {
1385
+ name: nameMatch[1].trim(),
1386
+ path: pathMatch ? pathMatch[1].trim() : void 0,
1387
+ mime: mimeMatch ? mimeMatch[1].trim() : void 0,
1388
+ size: sizeMatch ? Number(sizeMatch[1]) : void 0,
1389
+ continued: isContinuePass
1390
+ };
1244
1391
  } else {
1245
1392
  displayContent = userText;
1246
1393
  }
@@ -1252,6 +1399,7 @@ function mapHistoryListToMessages(list, platform, opts) {
1252
1399
  if (isQueued) userMsg.isPendingQueued = true;
1253
1400
  if (isCancelledItem) userMsg.isCancelled = true;
1254
1401
  if (item._isBgTask) userMsg.isBackgroundTask = true;
1402
+ if (indexFile) userMsg._indexFile = indexFile;
1255
1403
  if (item._isOnBgQueue) userMsg._useBgQueue = true;
1256
1404
  if (serverItemId !== void 0) userMsg._serverItemId = serverItemId;
1257
1405
  mapped.push(userMsg);
@@ -1279,6 +1427,91 @@ function mapHistoryListToMessages(list, platform, opts) {
1279
1427
  return { messages: mapped, runningItemIds };
1280
1428
  }
1281
1429
 
1430
+ // src/engine/viewport_fill.ts
1431
+ var HISTORY_FILL_SLACK_PX = 64;
1432
+ var MAX_HISTORY_FILL_PAGES = 24;
1433
+ var IDLE_WAIT_STEP_MS = 120;
1434
+ var IDLE_WAIT_MAX_MS = 15e3;
1435
+ async function waitForIdle(opts, stale) {
1436
+ var waited = 0;
1437
+ while (opts.isLoading()) {
1438
+ if (stale() || waited >= IDLE_WAIT_MAX_MS) return false;
1439
+ await new Promise(function(r) {
1440
+ setTimeout(r, IDLE_WAIT_STEP_MS);
1441
+ });
1442
+ waited += IDLE_WAIT_STEP_MS;
1443
+ }
1444
+ return !stale();
1445
+ }
1446
+ async function fillHistoryViewport(opts) {
1447
+ var maxPages = typeof opts.maxPages === "number" ? opts.maxPages : MAX_HISTORY_FILL_PAGES;
1448
+ var stale = function() {
1449
+ return !!(opts.isStale && opts.isStale());
1450
+ };
1451
+ var swallowed = 0;
1452
+ for (var page = 0; page < maxPages; page++) {
1453
+ if (stale() || opts.isEndOfList()) return;
1454
+ if (!await waitForIdle(opts, stale)) return;
1455
+ var satisfied = false;
1456
+ try {
1457
+ satisfied = !!await opts.isSatisfied();
1458
+ } catch {
1459
+ return;
1460
+ }
1461
+ if (satisfied || stale()) return;
1462
+ if (!await waitForIdle(opts, stale)) return;
1463
+ var before = opts.messageCount();
1464
+ var attempted;
1465
+ try {
1466
+ attempted = await opts.fetchOlder();
1467
+ } catch {
1468
+ return;
1469
+ }
1470
+ if (stale()) return;
1471
+ if (attempted === false) {
1472
+ if (++swallowed > 3) return;
1473
+ page--;
1474
+ continue;
1475
+ }
1476
+ if (opts.messageCount() <= before) return;
1477
+ }
1478
+ }
1479
+ function createHistoryFiller(base) {
1480
+ var pending = [];
1481
+ var running = false;
1482
+ async function allSatisfied() {
1483
+ var next = [];
1484
+ for (var i = 0; i < pending.length; i++) {
1485
+ if (!await pending[i]()) next.push(pending[i]);
1486
+ }
1487
+ pending = next;
1488
+ return pending.length === 0;
1489
+ }
1490
+ return {
1491
+ isRunning: function() {
1492
+ return running;
1493
+ },
1494
+ fill: function(isSatisfied) {
1495
+ pending.push(isSatisfied);
1496
+ if (running) return Promise.resolve();
1497
+ running = true;
1498
+ var done = function() {
1499
+ running = false;
1500
+ pending = [];
1501
+ };
1502
+ return fillHistoryViewport({
1503
+ isSatisfied: allSatisfied,
1504
+ isEndOfList: base.isEndOfList,
1505
+ isLoading: base.isLoading,
1506
+ messageCount: base.messageCount,
1507
+ fetchOlder: base.fetchOlder,
1508
+ isStale: base.isStale,
1509
+ maxPages: base.maxPages
1510
+ }).then(done, done);
1511
+ }
1512
+ };
1513
+ }
1514
+
1282
1515
  // src/engine/session.ts
1283
1516
  var _g = typeof globalThis !== "undefined" ? globalThis : {};
1284
1517
  function nowMs() {
@@ -1316,6 +1549,7 @@ var ChatSession = class {
1316
1549
  };
1317
1550
  this.bgTaskQueue = [];
1318
1551
  this.cancelledServerIds = /* @__PURE__ */ new Set();
1552
+ this.cancelledIndexKeys = /* @__PURE__ */ new Set();
1319
1553
  this.pendingAgentRequests = {};
1320
1554
  this.aiChatHistoryCache = {};
1321
1555
  this.historyItemPolls = /* @__PURE__ */ new Map();
@@ -1338,6 +1572,23 @@ var ChatSession = class {
1338
1572
  this.historyItemPolls.set(id, { kind, stop });
1339
1573
  return p;
1340
1574
  }
1575
+ /**
1576
+ * Stop and forget one item's poll. Used after a cancel: the row is either gone
1577
+ * (cancelled while queued) or flagged cancelled (cancelled while running), so
1578
+ * asking about it again only burns requests. Safe when no poll is attached, and
1579
+ * safe on an older skapi-js with no stop handle (the entry is then LEFT in the
1580
+ * map so a later drain cannot stack a second, unstoppable poll on the id).
1581
+ */
1582
+ _stopPoll(id) {
1583
+ var handle = this.historyItemPolls.get(id);
1584
+ if (!handle) return;
1585
+ if (typeof handle.stop !== "function") return;
1586
+ try {
1587
+ handle.stop();
1588
+ } catch (e) {
1589
+ }
1590
+ this.historyItemPolls.delete(id);
1591
+ }
1341
1592
  /** True while any pause reason is active. */
1342
1593
  isPollingPaused() {
1343
1594
  return this._pauseReasons.size > 0;
@@ -1440,7 +1691,18 @@ var ChatSession = class {
1440
1691
  if (reply._serverItemId === void 0 && msgs[thIdx]._serverItemId !== void 0) reply._serverItemId = msgs[thIdx]._serverItemId;
1441
1692
  msgs[thIdx] = reply;
1442
1693
  } else {
1443
- msgs.push(reply);
1694
+ var dupIdx = -1;
1695
+ if (serverId) {
1696
+ for (var d = msgs.length - 1; d >= 0; d--) {
1697
+ var dm = msgs[d];
1698
+ if (dm && dm.role === "assistant" && dm._serverItemId === serverId) {
1699
+ dupIdx = d;
1700
+ break;
1701
+ }
1702
+ }
1703
+ }
1704
+ if (dupIdx !== -1) msgs[dupIdx] = reply;
1705
+ else msgs.push(reply);
1444
1706
  }
1445
1707
  for (var j = 0; j < msgs.length; j++) {
1446
1708
  var u = msgs[j];
@@ -1680,7 +1942,7 @@ var ChatSession = class {
1680
1942
  self.state.sending = false;
1681
1943
  if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
1682
1944
  return Promise.resolve(self.typewriteLatestReply(key)).then(function() {
1683
- self.host.scrollToBottom(true);
1945
+ self.host.scrollToBottomIfSticky(true);
1684
1946
  });
1685
1947
  });
1686
1948
  }
@@ -1694,6 +1956,7 @@ var ChatSession = class {
1694
1956
  if (nextIdx === -1) return;
1695
1957
  var existing = this.state.messages[nextIdx];
1696
1958
  var promoted = { role: "user", content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
1959
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
1697
1960
  if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
1698
1961
  if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
1699
1962
  this.state.messages[nextIdx] = promoted;
@@ -1714,6 +1977,7 @@ var ChatSession = class {
1714
1977
  var existing = this.state.messages[nextIdx];
1715
1978
  var promoted = { role: "user", content: existing.content, isPendingInProcess: true };
1716
1979
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
1980
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
1717
1981
  if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
1718
1982
  if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
1719
1983
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
@@ -1814,7 +2078,7 @@ var ChatSession = class {
1814
2078
  this.promoteNextQueuedToRunning();
1815
2079
  this.updateHistoryCache();
1816
2080
  this.host.notify();
1817
- this.host.scrollToBottom(true);
2081
+ this.host.scrollToBottomIfSticky(true);
1818
2082
  }
1819
2083
  onQueuedSendError(_composed, err, serverId, ownerKey) {
1820
2084
  if (serverId) this.historyItemPolls.delete(serverId);
@@ -1864,7 +2128,7 @@ var ChatSession = class {
1864
2128
  this.promoteNextQueuedToRunning();
1865
2129
  this.updateHistoryCache();
1866
2130
  this.host.notify();
1867
- this.host.scrollToBottom(true);
2131
+ this.host.scrollToBottomIfSticky(true);
1868
2132
  return;
1869
2133
  }
1870
2134
  var targetIdx = this.resolveQueuedUserBubble(serverId);
@@ -1878,7 +2142,7 @@ var ChatSession = class {
1878
2142
  this.promoteNextQueuedToRunning();
1879
2143
  this.updateHistoryCache();
1880
2144
  this.host.notify();
1881
- this.host.scrollToBottom(true);
2145
+ this.host.scrollToBottomIfSticky(true);
1882
2146
  }
1883
2147
  cancelQueuedMessage(msg, idx) {
1884
2148
  var self = this;
@@ -1902,6 +2166,7 @@ var ChatSession = class {
1902
2166
  })).then(function(result) {
1903
2167
  if (result && result.removed) {
1904
2168
  self.cancelledServerIds.add(serverId);
2169
+ self._stopPoll(serverId);
1905
2170
  var qi = self.bgTaskQueue.findIndex(function(e) {
1906
2171
  return e.id === serverId;
1907
2172
  });
@@ -1910,7 +2175,13 @@ var ChatSession = class {
1910
2175
  return m._serverItemId === serverId && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user";
1911
2176
  });
1912
2177
  if (removeIdx !== -1) {
1913
- self.state.messages[removeIdx] = { role: "user", content: self.state.messages[removeIdx].content, isCancelled: true, _serverItemId: serverId };
2178
+ var wasMsg = self.state.messages[removeIdx];
2179
+ var cancelledMsg = { role: "user", content: wasMsg.content, isCancelled: true, _serverItemId: serverId };
2180
+ if (wasMsg.isBackgroundTask) cancelledMsg.isBackgroundTask = true;
2181
+ if (wasMsg._indexFile) cancelledMsg._indexFile = wasMsg._indexFile;
2182
+ if (wasMsg._useBgQueue) cancelledMsg._useBgQueue = true;
2183
+ if (wasMsg._ownerKey !== void 0) cancelledMsg._ownerKey = wasMsg._ownerKey;
2184
+ self.state.messages[removeIdx] = cancelledMsg;
1914
2185
  var thById = self.state.messages.findIndex(function(m) {
1915
2186
  return m._serverItemId === serverId && m.isPending && m.role === "assistant";
1916
2187
  });
@@ -1947,6 +2218,42 @@ var ChatSession = class {
1947
2218
  }
1948
2219
  });
1949
2220
  }
2221
+ /**
2222
+ * Stop indexing a file, from its collapsed row — every pass at once, not just
2223
+ * the bubble the user happens to see.
2224
+ *
2225
+ * A big file is indexed as a CHAIN of passes, so cancelling only the live one
2226
+ * accomplishes nothing: the next pass is dispatched as soon as it settles.
2227
+ * Three things end the chain:
2228
+ * 1. every queued/running pass of this file is cancelled server-side
2229
+ * (csr-cancel deletes a queued row and flags a running one "cancelled",
2230
+ * which is also the worker's gate for NOT enqueueing the next window);
2231
+ * 2. the file is remembered in cancelledIndexKeys, so the client-driven
2232
+ * resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
2233
+ * 3. any of its passes still sitting in bgTaskQueue is dropped by the next
2234
+ * drain rather than surfacing a fresh "Indexing…" bubble.
2235
+ *
2236
+ * Records already written by the passes that DID run are kept — this stops the
2237
+ * work, it does not undo it.
2238
+ */
2239
+ cancelIndexingGroup(group) {
2240
+ var self = this;
2241
+ if (!group || !group.key) return;
2242
+ var scoped = this.getHistoryCacheKey() + "|" + group.key;
2243
+ this.cancelledIndexKeys.add(scoped);
2244
+ var ids = group.cancellableIds || [];
2245
+ if (!ids.length) {
2246
+ this.host.notify();
2247
+ return;
2248
+ }
2249
+ ids.forEach(function(serverId) {
2250
+ var idx = self.state.messages.findIndex(function(m) {
2251
+ return m._serverItemId === serverId && m.role === "user" && (m.isPendingQueued || m.isPendingInProcess);
2252
+ });
2253
+ if (idx === -1) return;
2254
+ self.cancelQueuedMessage(self.state.messages[idx], idx);
2255
+ });
2256
+ }
1950
2257
  // --- typewriter -------------------------------------------------------
1951
2258
  // Reveal `fullText` into a message bubble at a constant wall-clock RATE
1952
2259
  // (chars/second) driven by requestAnimationFrame, rather than a fixed number
@@ -2133,6 +2440,7 @@ var ChatSession = class {
2133
2440
  var u = this.state.messages[uIdx];
2134
2441
  var cleaned = { role: "user", content: u.content, _serverItemId: itemId };
2135
2442
  if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
2443
+ if (u._indexFile) cleaned._indexFile = u._indexFile;
2136
2444
  this.state.messages[uIdx] = cleaned;
2137
2445
  }
2138
2446
  // If an immediate-send request for the current cache key is still in flight
@@ -2150,13 +2458,13 @@ var ChatSession = class {
2150
2458
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
2151
2459
  })) return Promise.resolve();
2152
2460
  this.state.sending = true;
2153
- this.host.scrollToBottom(true);
2461
+ this.host.scrollToBottomIfSticky(true);
2154
2462
  return Promise.resolve(pending).catch(function() {
2155
2463
  }).then(function() {
2156
2464
  if (token !== self.state.gateRefreshToken) return;
2157
2465
  self.state.sending = false;
2158
2466
  return Promise.resolve(self.typewriteLatestReply(key)).then(function() {
2159
- self.host.scrollToBottom(true);
2467
+ self.host.scrollToBottomIfSticky(true);
2160
2468
  });
2161
2469
  });
2162
2470
  }
@@ -2175,8 +2483,17 @@ var ChatSession = class {
2175
2483
  });
2176
2484
  if (idx !== -1) {
2177
2485
  this._clearPendingUserBubble(itemId);
2486
+ var wasBgTask = !!this.state.messages[idx].isBackgroundTask;
2178
2487
  if (isErr) {
2179
2488
  this.state.messages[idx] = { role: "assistant", content: answer, isError: true, _serverItemId: itemId };
2489
+ if (wasBgTask) this.state.messages[idx].isBackgroundTask = true;
2490
+ this.host.notify();
2491
+ this.updateHistoryCache();
2492
+ return;
2493
+ }
2494
+ var text = answer || "No text response received from AI provider.";
2495
+ if (wasBgTask) {
2496
+ this.state.messages[idx] = { role: "assistant", content: text, isBackgroundTask: true, _serverItemId: itemId };
2180
2497
  this.host.notify();
2181
2498
  this.updateHistoryCache();
2182
2499
  return;
@@ -2184,7 +2501,7 @@ var ChatSession = class {
2184
2501
  var lid = this._newLocalId();
2185
2502
  this.state.messages[idx] = { role: "assistant", content: "", _localId: lid, _serverItemId: itemId };
2186
2503
  this.host.notify();
2187
- this.enqueueTypewrite(idx, answer || "No text response received from AI provider.", lid);
2504
+ this.enqueueTypewrite(idx, text, lid);
2188
2505
  this.updateHistoryCache();
2189
2506
  return;
2190
2507
  }
@@ -2193,25 +2510,124 @@ var ChatSession = class {
2193
2510
  });
2194
2511
  if (userIdx === -1) return;
2195
2512
  var ex = this.state.messages[userIdx];
2196
- this.state.messages[userIdx] = { role: "user", content: ex.content, _serverItemId: itemId };
2513
+ var settledUser = { role: "user", content: ex.content, _serverItemId: itemId };
2514
+ if (ex.isBackgroundTask) settledUser.isBackgroundTask = true;
2515
+ if (ex._indexFile) settledUser._indexFile = ex._indexFile;
2516
+ if (ex._useBgQueue) settledUser._useBgQueue = true;
2517
+ this.state.messages[userIdx] = settledUser;
2197
2518
  if (isErr) {
2198
- this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: answer, isError: true, _serverItemId: itemId });
2519
+ var errReply = { role: "assistant", content: answer, isError: true, _serverItemId: itemId };
2520
+ if (ex.isBackgroundTask) errReply.isBackgroundTask = true;
2521
+ this.state.messages.splice(userIdx + 1, 0, errReply);
2522
+ this.host.notify();
2523
+ this.updateHistoryCache();
2524
+ return;
2525
+ }
2526
+ var text2 = answer || "No text response received from AI provider.";
2527
+ if (ex.isBackgroundTask) {
2528
+ this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: text2, isBackgroundTask: true, _serverItemId: itemId });
2199
2529
  this.host.notify();
2200
2530
  this.updateHistoryCache();
2201
2531
  return;
2202
2532
  }
2203
2533
  var lid2 = this._newLocalId();
2204
- this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: "", _localId: lid2, _serverItemId: itemId });
2534
+ var reply = { role: "assistant", content: "", _localId: lid2, _serverItemId: itemId };
2535
+ this.state.messages.splice(userIdx + 1, 0, reply);
2205
2536
  this.host.notify();
2206
- this.enqueueTypewrite(userIdx + 1, answer || "No text response received from AI provider.", lid2);
2537
+ this.enqueueTypewrite(userIdx + 1, text2, lid2);
2207
2538
  this.updateHistoryCache();
2208
2539
  }
2540
+ /** How a bg task maps onto a collapsed row: the row's own key (storage path
2541
+ * when known, else the filename), scoped to the chat it belongs to. A storage
2542
+ * path is project-relative ("report.xlsx"), and ONE ChatSession serves every
2543
+ * project — unscoped, stopping a file in one project would silently suppress
2544
+ * the same filename's continuations in another. */
2545
+ _indexKeyOf(entry) {
2546
+ if (!entry) return "";
2547
+ var file = entry.storagePath || entry.filename;
2548
+ if (!file) return "";
2549
+ return entry.serviceId + "#" + entry.platform + "|" + file;
2550
+ }
2551
+ /**
2552
+ * Reconcile the bg queue with the files the user has stopped.
2553
+ *
2554
+ * A FIRST pass (no resumePass) is a fresh indexing request — a re-upload, or a
2555
+ * Reindex from the file manager — so it LIFTS the stop: the key is a storage
2556
+ * path, and without this an earlier cancel would silently kill every future
2557
+ * index of the same path. A continuation of a stopped file is dropped instead,
2558
+ * covering the pass that was dispatched in the moment before the cancel landed.
2559
+ */
2560
+ _applyIndexCancellations() {
2561
+ if (!this.cancelledIndexKeys.size) return;
2562
+ for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
2563
+ var entry = this.bgTaskQueue[i];
2564
+ var key = this._indexKeyOf(entry);
2565
+ if (!key || !this.cancelledIndexKeys.has(key)) continue;
2566
+ if (!entry.resumePass) {
2567
+ this.cancelledIndexKeys.delete(key);
2568
+ continue;
2569
+ }
2570
+ this.bgTaskQueue.splice(i, 1);
2571
+ this._stopPoll(entry.id);
2572
+ this._cancelServerItem(entry.id);
2573
+ }
2574
+ }
2575
+ /**
2576
+ * Cancel any live pass of a stopped file that turned up on its own.
2577
+ *
2578
+ * The client is not the only thing that continues a file: for PDFs and (when
2579
+ * windowed indexing is on) text/grid files the WORKER enqueues the next window
2580
+ * itself, and that pass reaches the chat through the history poll, never
2581
+ * through bgTaskQueue. The worker's own gate stops the chain when the running
2582
+ * row is cancelled — but if the row had already finished when the user hit
2583
+ * stop, the next window was queued a moment earlier and still arrives. Stop it
2584
+ * here rather than making the user hit stop again.
2585
+ *
2586
+ * Runs from drainBgTaskQueue, which both clients call after a history load.
2587
+ */
2588
+ _sweepCancelledIndexing() {
2589
+ if (!this.cancelledIndexKeys.size) return;
2590
+ var self = this;
2591
+ var chatKey = this.getHistoryCacheKey();
2592
+ var targets = [];
2593
+ this.state.messages.forEach(function(m, i) {
2594
+ if (!m.isBackgroundTask || m.role !== "user" || !m._serverItemId) return;
2595
+ if (m._cancelling || m.isSendingToServer) return;
2596
+ if (!(m.isPendingQueued || m.isPendingInProcess)) return;
2597
+ var ref = m._indexFile;
2598
+ var file = ref && (ref.path || ref.name);
2599
+ if (!file || !self.cancelledIndexKeys.has(chatKey + "|" + file)) return;
2600
+ targets.push({ msg: m, idx: i });
2601
+ });
2602
+ targets.forEach(function(t) {
2603
+ var idx = self.state.messages.indexOf(t.msg);
2604
+ self.cancelQueuedMessage(t.msg, idx === -1 ? t.idx : idx);
2605
+ });
2606
+ }
2607
+ /** Best-effort server-side cancel of a bg-queue item that has no bubble (so
2608
+ * cancelQueuedMessage, which drives one, has nothing to act on). */
2609
+ _cancelServerItem(serverId) {
2610
+ var id = this.host.getIdentity();
2611
+ if (!serverId || id.platform !== "claude" && id.platform !== "openai") return;
2612
+ var url = id.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
2613
+ Promise.resolve(this.host.cancelRequest({
2614
+ url,
2615
+ method: "POST",
2616
+ id: serverId,
2617
+ queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
2618
+ service: id.serviceId,
2619
+ owner: id.owner
2620
+ })).catch(function() {
2621
+ });
2622
+ }
2209
2623
  // Inject "Indexing: <file>" bubbles for queued bg tasks + attach their polls.
2210
2624
  drainBgTaskQueue() {
2211
2625
  var self = this;
2212
2626
  var id = this.host.getIdentity();
2213
2627
  var svcId = id.serviceId, plat = id.platform;
2214
2628
  if (!svcId || plat === "none" || !this.host.isViewMounted()) return;
2629
+ this._applyIndexCancellations();
2630
+ this._sweepCancelledIndexing();
2215
2631
  var presentIds = {};
2216
2632
  var pendingIds = {};
2217
2633
  this.state.messages.forEach(function(m) {
@@ -2229,7 +2645,22 @@ var ChatSession = class {
2229
2645
  if (entry.serviceId !== svcId || entry.platform !== plat) return;
2230
2646
  if (!presentIds[entry.id]) {
2231
2647
  var isRunning = entry.status === "running";
2232
- var userBubble = { role: "user", content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex), isBackgroundTask: true, _serverItemId: entry.id };
2648
+ var userBubble = {
2649
+ role: "user",
2650
+ content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex, !!entry.resumePass),
2651
+ isBackgroundTask: true,
2652
+ _serverItemId: entry.id,
2653
+ // Structured ref so this live pass groups with the same file's passes
2654
+ // rebuilt from history (see indexing_groups.buildChatDisplayList).
2655
+ _indexFile: {
2656
+ name: entry.filename,
2657
+ path: entry.storagePath,
2658
+ mime: entry.mime,
2659
+ size: entry.size,
2660
+ isReindex: !!entry.isReindex,
2661
+ continued: !!entry.resumePass
2662
+ }
2663
+ };
2233
2664
  if (isRunning) userBubble.isPendingInProcess = true;
2234
2665
  else userBubble.isPendingQueued = true;
2235
2666
  self.state.messages.push(userBubble);
@@ -2239,7 +2670,7 @@ var ChatSession = class {
2239
2670
  presentIds[entry.id] = true;
2240
2671
  self.host.notify();
2241
2672
  self.updateHistoryCache();
2242
- self.host.scrollToBottom(false);
2673
+ self.host.scrollToBottomIfSticky(false);
2243
2674
  }
2244
2675
  if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
2245
2676
  var capturedId = entry.id, capturedPlat = plat;
@@ -2257,15 +2688,23 @@ var ChatSession = class {
2257
2688
  }).catch(function(err) {
2258
2689
  self.historyItemPolls.delete(capturedId);
2259
2690
  var isNotExists = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
2691
+ self._clearPendingUserBubble(capturedId);
2260
2692
  var bi = self.state.messages.findIndex(function(m) {
2261
2693
  return m.isPending && m._serverItemId === capturedId;
2262
2694
  });
2263
2695
  if (bi !== -1) {
2264
2696
  if (isNotExists) self.state.messages.splice(bi, 1);
2265
2697
  else self.state.messages[bi] = { role: "assistant", content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId };
2266
- self.host.notify();
2267
- self.updateHistoryCache();
2698
+ } else if (!isNotExists) {
2699
+ var ui = self.state.messages.findIndex(function(m) {
2700
+ return m.role === "user" && m._serverItemId === capturedId;
2701
+ });
2702
+ if (ui !== -1) {
2703
+ self.state.messages.splice(ui + 1, 0, { role: "assistant", content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId });
2704
+ }
2268
2705
  }
2706
+ self.host.notify();
2707
+ self.updateHistoryCache();
2269
2708
  }).then(function() {
2270
2709
  if (wasStopped) return;
2271
2710
  var qi = self.bgTaskQueue.findIndex(function(q) {
@@ -2293,8 +2732,10 @@ var ChatSession = class {
2293
2732
  var self = this;
2294
2733
  try {
2295
2734
  if (!entry || !entry.storagePath) return;
2735
+ if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
2296
2736
  if (!isPagedReadFile(entry.filename, entry.mime)) return;
2297
2737
  if (isImageVisionFile(entry.filename, entry.mime)) return;
2738
+ if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
2298
2739
  if (isErrorResponseBody(response)) return;
2299
2740
  var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
2300
2741
  if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return;
@@ -2387,6 +2828,7 @@ var ChatSession = class {
2387
2828
  serviceId: id.serviceId,
2388
2829
  formatIndexingLabel: self.host.formatIndexingLabel
2389
2830
  }).messages;
2831
+ var keptOlderPages = false;
2390
2832
  if (fetchMore) {
2391
2833
  self.state.messages = mapped.concat(self.state.messages);
2392
2834
  } else {
@@ -2397,7 +2839,12 @@ var ChatSession = class {
2397
2839
  });
2398
2840
  var locallyCancelled = {};
2399
2841
  self.state.messages.forEach(function(m) {
2400
- if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = 1;
2842
+ if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = m;
2843
+ });
2844
+ var inFlightCancel = {};
2845
+ self.state.messages.forEach(function(m) {
2846
+ if (!m._serverItemId) return;
2847
+ if (m._cancelling || m._cancelError) inFlightCancel[m._serverItemId] = m;
2401
2848
  });
2402
2849
  var mappedHasPendingAssistant = mapped.some(function(m) {
2403
2850
  return m.isPending && m.role === "assistant" && !m.isBackgroundTask;
@@ -2417,7 +2864,22 @@ var ChatSession = class {
2417
2864
  }
2418
2865
  }
2419
2866
  }
2420
- self.state.messages = mapped;
2867
+ var oldestInPage1 = void 0;
2868
+ mapped.forEach(function(m) {
2869
+ var sid = m._serverItemId;
2870
+ if (typeof sid !== "string") return;
2871
+ if (oldestInPage1 === void 0 || sid < oldestInPage1) oldestInPage1 = sid;
2872
+ });
2873
+ var sharesPage1 = self.state.messages.some(function(m) {
2874
+ return typeof m._serverItemId === "string" && !!serverIds[m._serverItemId];
2875
+ });
2876
+ var retainedOlder = !sharesPage1 || oldestInPage1 === void 0 ? [] : self.state.messages.filter(function(m) {
2877
+ if (typeof m._serverItemId !== "string") return false;
2878
+ if (m._ownerKey !== void 0 && m._ownerKey !== loadKey) return false;
2879
+ return m._serverItemId < oldestInPage1;
2880
+ });
2881
+ keptOlderPages = retainedOlder.length > 0;
2882
+ self.state.messages = keptOlderPages ? retainedOlder.concat(mapped) : mapped;
2421
2883
  rescued.forEach(function(m) {
2422
2884
  self.state.messages.push(m);
2423
2885
  });
@@ -2425,19 +2887,38 @@ var ChatSession = class {
2425
2887
  for (var ci = 0; ci < self.state.messages.length; ci++) {
2426
2888
  var c = self.state.messages[ci];
2427
2889
  if (!c._serverItemId || !locallyCancelled[c._serverItemId] || c.isCancelled) continue;
2428
- self.state.messages[ci] = { role: "user", content: c.content, isCancelled: true, _serverItemId: c._serverItemId };
2890
+ self.state.messages[ci] = {
2891
+ role: "user",
2892
+ content: c.content,
2893
+ isCancelled: true,
2894
+ _serverItemId: c._serverItemId,
2895
+ isBackgroundTask: c.isBackgroundTask,
2896
+ _indexFile: c._indexFile,
2897
+ _useBgQueue: c._useBgQueue,
2898
+ _ownerKey: c._ownerKey
2899
+ };
2429
2900
  if (ci + 1 < self.state.messages.length && self.state.messages[ci + 1].isPending && self.state.messages[ci + 1]._serverItemId === c._serverItemId) {
2430
2901
  self.state.messages.splice(ci + 1, 1);
2431
2902
  }
2432
2903
  }
2433
2904
  }
2905
+ for (var fi = 0; fi < self.state.messages.length; fi++) {
2906
+ var fm = self.state.messages[fi];
2907
+ var was = fm._serverItemId && inFlightCancel[fm._serverItemId];
2908
+ if (!was || fm.isCancelled) continue;
2909
+ if (!(fm.isPendingQueued || fm.isPendingInProcess || fm.isPending)) continue;
2910
+ if (was._cancelling) fm._cancelling = true;
2911
+ if (was._cancelError) fm._cancelError = was._cancelError;
2912
+ }
2434
2913
  }
2435
- self.state.historyEndOfList = !!(history && history.endOfList);
2436
- self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
2437
- var clearedAt = self.host.getClearedAt();
2438
- if (clearedAt && chatList.length > 0) {
2439
- var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
2440
- if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
2914
+ if (!keptOlderPages) {
2915
+ self.state.historyEndOfList = !!(history && history.endOfList);
2916
+ self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
2917
+ var clearedAt = self.host.getClearedAt();
2918
+ if (clearedAt && chatList.length > 0) {
2919
+ var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
2920
+ if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
2921
+ }
2441
2922
  }
2442
2923
  if (self.state.historyRequestToken === token) {
2443
2924
  self.state.loadingHistory = false;
@@ -2466,18 +2947,25 @@ var ChatSession = class {
2466
2947
  return m.isPending && m._serverItemId === capturedId;
2467
2948
  });
2468
2949
  if (isNotExists) {
2469
- var isBg = aIdx !== -1 ? !!self.state.messages[aIdx].isBackgroundTask : false;
2950
+ var uIdx = self.state.messages.findIndex(function(m) {
2951
+ return m.role === "user" && m._serverItemId === capturedId && !m.isCancelled;
2952
+ });
2953
+ var isBg = aIdx !== -1 && !!self.state.messages[aIdx].isBackgroundTask || uIdx !== -1 && !!self.state.messages[uIdx].isBackgroundTask;
2470
2954
  if (aIdx !== -1) self.state.messages.splice(aIdx, 1);
2471
2955
  if (!isBg) {
2472
- var uIdx = self.state.messages.findIndex(function(m) {
2473
- return m.role === "user" && m._serverItemId === capturedId && !m.isCancelled;
2474
- });
2475
2956
  if (uIdx !== -1) {
2476
2957
  var ex = self.state.messages[uIdx];
2477
2958
  self.state.messages[uIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
2478
2959
  }
2479
2960
  self.cancelledServerIds.delete(capturedId);
2480
2961
  self.promoteNextQueuedToRunning();
2962
+ } else if (uIdx !== -1) {
2963
+ var bex = self.state.messages[uIdx];
2964
+ var bcancelled = { role: "user", content: bex.content, isCancelled: true, isBackgroundTask: true, _serverItemId: bex._serverItemId };
2965
+ if (bex._indexFile) bcancelled._indexFile = bex._indexFile;
2966
+ if (bex._useBgQueue) bcancelled._useBgQueue = true;
2967
+ self.state.messages[uIdx] = bcancelled;
2968
+ self.promoteNextBgQueuedToRunning();
2481
2969
  }
2482
2970
  self.host.notify();
2483
2971
  self.updateHistoryCache();
@@ -2498,7 +2986,7 @@ var ChatSession = class {
2498
2986
  });
2499
2987
  self.drainBgTaskQueue();
2500
2988
  }
2501
- if (!fetchMore) return self.host.scrollToBottom();
2989
+ if (!fetchMore) return self.host.scrollToBottomIfSticky();
2502
2990
  }).catch(function(err) {
2503
2991
  console.warn("[chat-engine] getChatHistory failed", err);
2504
2992
  }).then(function() {
@@ -2508,6 +2996,7 @@ var ChatSession = class {
2508
2996
  self.state.loadingOlderHistory = false;
2509
2997
  if (wasLoading) self.host.notify();
2510
2998
  }
2999
+ if (self.host.onHistoryLoaded) self.host.onHistoryLoaded(!!fetchMore, token);
2511
3000
  });
2512
3001
  }
2513
3002
  // --- attachment upload orchestration ---------------------------------
@@ -2705,6 +3194,189 @@ var ChatSession = class {
2705
3194
  }
2706
3195
  };
2707
3196
 
3197
+ // src/engine/indexing_groups.ts
3198
+ var INDEXING_LABEL_RE = /^(Re)?[Ii]ndexing(\s*\(continuing\))?\s*:?\s+(.+)$/;
3199
+ var LEADING_MD_LINK_RE = /^\[([^\]]+)\]\(([^)]+)\)/;
3200
+ function parseIndexingLabel(content) {
3201
+ if (typeof content !== "string" || !content) return null;
3202
+ var firstLine = content.split("\n")[0].trim();
3203
+ var m = firstLine.match(INDEXING_LABEL_RE);
3204
+ if (!m) return null;
3205
+ var head = m[3].split(" \xB7 ")[0].trim();
3206
+ var link = head.match(LEADING_MD_LINK_RE);
3207
+ var name = link ? link[1].trim() : head;
3208
+ if (!name) return null;
3209
+ return {
3210
+ name,
3211
+ path: link ? link[2].trim() : void 0,
3212
+ continued: !!m[2],
3213
+ isReindex: !!m[1]
3214
+ };
3215
+ }
3216
+ function readFileRef(msg) {
3217
+ var ref = msg && msg._indexFile;
3218
+ if (ref && (ref.path || ref.name)) {
3219
+ return {
3220
+ name: ref.name || ref.path || "",
3221
+ path: ref.path,
3222
+ mime: ref.mime,
3223
+ size: ref.size,
3224
+ isReindex: ref.isReindex,
3225
+ continued: !!ref.continued
3226
+ };
3227
+ }
3228
+ var parsed = parseIndexingLabel(msg && msg.content);
3229
+ if (!parsed) return null;
3230
+ return {
3231
+ name: parsed.name,
3232
+ path: parsed.path,
3233
+ isReindex: parsed.isReindex,
3234
+ continued: parsed.continued
3235
+ };
3236
+ }
3237
+ function isPendingMsg(m) {
3238
+ return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
3239
+ }
3240
+ function buildChatDisplayList(messages, opts) {
3241
+ var list = Array.isArray(messages) ? messages : [];
3242
+ var hasMoreHistory = !!(opts && opts.hasMoreHistory);
3243
+ var groups = {};
3244
+ var order = [];
3245
+ var runOfIndex = new Array(list.length);
3246
+ var runByItemId = {};
3247
+ var keyByName = {};
3248
+ var openRunOfKey = {};
3249
+ var runsOfKey = {};
3250
+ var keyOfRun = {};
3251
+ var runSeq = 0;
3252
+ for (var i = 0; i < list.length; i++) {
3253
+ var msg = list[i];
3254
+ if (!msg || !msg.isBackgroundTask) continue;
3255
+ var runId;
3256
+ var ref = msg.role === "user" ? readFileRef(msg) : null;
3257
+ if (ref) {
3258
+ var key = ref.path || keyByName[ref.name] || ref.name;
3259
+ if (!ref.continued && openRunOfKey[key]) delete openRunOfKey[key];
3260
+ runId = openRunOfKey[key];
3261
+ if (!runId) {
3262
+ runId = "run" + runSeq++;
3263
+ openRunOfKey[key] = runId;
3264
+ keyOfRun[runId] = key;
3265
+ (runsOfKey[key] || (runsOfKey[key] = [])).push(runId);
3266
+ }
3267
+ } else if (msg._serverItemId && runByItemId[msg._serverItemId]) {
3268
+ runId = runByItemId[msg._serverItemId];
3269
+ } else if (msg.role !== "user") {
3270
+ runId = runOfIndex[i - 1];
3271
+ }
3272
+ if (!runId) continue;
3273
+ var g = groups[runId];
3274
+ if (!g) {
3275
+ var fileKey = keyOfRun[runId];
3276
+ g = groups[runId] = {
3277
+ key: fileKey,
3278
+ runKey: runId,
3279
+ // provisional; renumbered newest-first below
3280
+ name: ref ? ref.name : fileKey,
3281
+ path: ref ? ref.path : void 0,
3282
+ mime: ref ? ref.mime : void 0,
3283
+ size: ref ? ref.size : void 0,
3284
+ isReindex: !!(ref && ref.isReindex),
3285
+ members: [],
3286
+ passCount: 0,
3287
+ status: "done",
3288
+ cancellableIds: [],
3289
+ cancelling: false,
3290
+ mayHaveOlder: false,
3291
+ anchorIndex: i
3292
+ };
3293
+ order.push(runId);
3294
+ }
3295
+ if (ref) {
3296
+ if (ref.name) g.name = ref.name;
3297
+ if (ref.path) g.path = ref.path;
3298
+ if (ref.mime) g.mime = ref.mime;
3299
+ if (typeof ref.size === "number") g.size = ref.size;
3300
+ if (ref.isReindex) g.isReindex = true;
3301
+ if (!ref.continued) g.mayHaveOlder = false;
3302
+ g.passCount++;
3303
+ }
3304
+ g.members.push({ msg, index: i });
3305
+ g.anchorIndex = i;
3306
+ runOfIndex[i] = runId;
3307
+ if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
3308
+ if (ref && ref.name) keyByName[ref.name] = g.key;
3309
+ }
3310
+ for (var rk in runsOfKey) {
3311
+ var runIds = runsOfKey[rk];
3312
+ for (var ri = 0; ri < runIds.length; ri++) {
3313
+ var grpR = groups[runIds[ri]];
3314
+ if (!grpR) continue;
3315
+ var first = grpR.members[0];
3316
+ var firstId = first && first.msg && (first.msg._serverItemId || first.msg._localId);
3317
+ grpR.runKey = rk + "#" + (firstId || "n" + ri);
3318
+ }
3319
+ }
3320
+ for (var oi = 0; oi < order.length; oi++) {
3321
+ var grp = groups[order[oi]];
3322
+ var lastSettled = -1;
3323
+ for (var si = 0; si < grp.members.length; si++) {
3324
+ if (!isPendingMsg(grp.members[si].msg)) lastSettled = si;
3325
+ }
3326
+ var active = false;
3327
+ for (var mi = lastSettled + 1; mi < grp.members.length; mi++) {
3328
+ if (isPendingMsg(grp.members[mi].msg)) {
3329
+ active = true;
3330
+ break;
3331
+ }
3332
+ }
3333
+ for (var xi = 0; xi < grp.members.length; xi++) {
3334
+ if (grp.members[xi].msg._cancelling) {
3335
+ grp.cancelling = true;
3336
+ break;
3337
+ }
3338
+ }
3339
+ var seenIds = {};
3340
+ for (var ci = 0; ci < grp.members.length; ci++) {
3341
+ var cm = grp.members[ci].msg;
3342
+ if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
3343
+ if (cm.role !== "user" || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
3344
+ if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
3345
+ if (ci < lastSettled) continue;
3346
+ if (seenIds[cm._serverItemId]) continue;
3347
+ seenIds[cm._serverItemId] = true;
3348
+ grp.cancellableIds.push(cm._serverItemId);
3349
+ }
3350
+ if (active) {
3351
+ grp.status = "active";
3352
+ } else {
3353
+ var last = grp.members[grp.members.length - 1].msg;
3354
+ grp.status = last.isError ? "error" : last.isCancelled ? "cancelled" : "done";
3355
+ }
3356
+ var sawFirstPass = false;
3357
+ for (var pi = 0; pi < grp.members.length; pi++) {
3358
+ var pm = grp.members[pi].msg;
3359
+ if (pm.role !== "user") continue;
3360
+ var pref = readFileRef(pm);
3361
+ if (pref && !pref.continued) {
3362
+ sawFirstPass = true;
3363
+ break;
3364
+ }
3365
+ }
3366
+ grp.mayHaveOlder = !sawFirstPass && hasMoreHistory;
3367
+ }
3368
+ var out = [];
3369
+ for (var j = 0; j < list.length; j++) {
3370
+ var r = runOfIndex[j];
3371
+ if (r === void 0) {
3372
+ out.push({ kind: "message", msg: list[j], index: j });
3373
+ continue;
3374
+ }
3375
+ if (groups[r].anchorIndex === j) out.push({ kind: "indexing", group: groups[r], index: j });
3376
+ }
3377
+ return out;
3378
+ }
3379
+
2708
3380
  exports.BG_INDEXING_QUEUE_SUFFIX = BG_INDEXING_QUEUE_SUFFIX;
2709
3381
  exports.CLAUDE_PER_REQUEST_INPUT_CAP = CLAUDE_PER_REQUEST_INPUT_CAP;
2710
3382
  exports.CONTEXT_WINDOW_BY_MODEL = CONTEXT_WINDOW_BY_MODEL;
@@ -2714,8 +3386,12 @@ exports.DEFAULT_CLAUDE_MODEL = DEFAULT_CLAUDE_MODEL;
2714
3386
  exports.DEFAULT_OPENAI_MODEL = DEFAULT_OPENAI_MODEL;
2715
3387
  exports.EXPIRED_ATTACHMENT_URL_HOST = EXPIRED_ATTACHMENT_URL_HOST;
2716
3388
  exports.EXPIRED_ATTACHMENT_URL_ORIGIN = EXPIRED_ATTACHMENT_URL_ORIGIN;
3389
+ exports.EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = EXPIRED_LINK_REFRESH_EXPIRES_SECONDS;
3390
+ exports.HISTORY_FILL_SLACK_PX = HISTORY_FILL_SLACK_PX;
2717
3391
  exports.HISTORY_TOKEN_BUDGET = HISTORY_TOKEN_BUDGET;
2718
3392
  exports.LINK_LABEL_MAX_DISPLAY_CHARS = LINK_LABEL_MAX_DISPLAY_CHARS;
3393
+ exports.LINK_REFRESH_WINDOW_MS = LINK_REFRESH_WINDOW_MS;
3394
+ exports.MAX_HISTORY_FILL_PAGES = MAX_HISTORY_FILL_PAGES;
2719
3395
  exports.MAX_HISTORY_MESSAGES = MAX_HISTORY_MESSAGES;
2720
3396
  exports.MAX_PARSED_CONTENT_CHARS = MAX_PARSED_CONTENT_CHARS;
2721
3397
  exports.MCP_NAME = MCP_NAME;
@@ -2725,6 +3401,7 @@ exports.POLL_INTERVAL = POLL_INTERVAL;
2725
3401
  exports.RENDER_FROM_TOKEN = RENDER_FROM_TOKEN;
2726
3402
  exports.TOOL_AND_RESPONSE_BUFFER = TOOL_AND_RESPONSE_BUFFER;
2727
3403
  exports.buildBoundedChatMessages = buildBoundedChatMessages;
3404
+ exports.buildChatDisplayList = buildChatDisplayList;
2728
3405
  exports.buildChatSystemPrompt = buildChatSystemPrompt;
2729
3406
  exports.buildDisplayExpiredAttachmentHref = buildDisplayExpiredAttachmentHref;
2730
3407
  exports.buildIndexingContinueMessage = buildIndexingContinueMessage;
@@ -2737,9 +3414,11 @@ exports.callClaudeWithMcp = callClaudeWithMcp;
2737
3414
  exports.callClaudeWithPublicMcp = callClaudeWithPublicMcp;
2738
3415
  exports.callOpenAIWithPublicMcp = callOpenAIWithPublicMcp;
2739
3416
  exports.chatEngineConfig = chatEngineConfig;
3417
+ exports.classifyInlineLink = classifyInlineLink;
2740
3418
  exports.clearAttachmentParsers = clearAttachmentParsers;
2741
3419
  exports.composeUserMessage = composeUserMessage;
2742
3420
  exports.configureChatEngine = configureChatEngine;
3421
+ exports.createHistoryFiller = createHistoryFiller;
2743
3422
  exports.createInlineLinkRegex = createInlineLinkRegex;
2744
3423
  exports.encodePathSegments = encodePathSegments;
2745
3424
  exports.estimateMessageTokens = estimateMessageTokens;
@@ -2748,6 +3427,7 @@ exports.extractClaudeText = extractClaudeText;
2748
3427
  exports.extractLastUserTextFromRequest = extractLastUserTextFromRequest;
2749
3428
  exports.extractOpenAIText = extractOpenAIText;
2750
3429
  exports.extractRemotePathFromAttachmentHref = extractRemotePathFromAttachmentHref;
3430
+ exports.fillHistoryViewport = fillHistoryViewport;
2751
3431
  exports.filterListByClearHorizon = filterListByClearHorizon;
2752
3432
  exports.findAttachmentParser = findAttachmentParser;
2753
3433
  exports.getAttachmentParsers = getAttachmentParsers;
@@ -2759,6 +3439,7 @@ exports.groupAttachmentFailures = groupAttachmentFailures;
2759
3439
  exports.isAuthExpiredError = isAuthExpiredError;
2760
3440
  exports.isBgIndexingQueue = isBgIndexingQueue;
2761
3441
  exports.isErrorResponseBody = isErrorResponseBody;
3442
+ exports.isHttpUrlLike = isHttpUrlLike;
2762
3443
  exports.isNonRetryableRequestError = isNonRetryableRequestError;
2763
3444
  exports.isOfficeFile = isOfficeFile;
2764
3445
  exports.isServerExtractable = isServerExtractable;
@@ -2769,9 +3450,13 @@ exports.makeExtractPlaceholder = makeExtractPlaceholder;
2769
3450
  exports.mapHistoryListToMessages = mapHistoryListToMessages;
2770
3451
  exports.normalizeAttachmentPathCandidate = normalizeAttachmentPathCandidate;
2771
3452
  exports.normalizeTextContent = normalizeTextContent;
3453
+ exports.normalizeTrailingInlineToken = normalizeTrailingInlineToken;
2772
3454
  exports.notifyAgentSaveAttachment = notifyAgentSaveAttachment;
2773
3455
  exports.parseAttachmentContent = parseAttachmentContent;
3456
+ exports.parseIndexingLabel = parseIndexingLabel;
3457
+ exports.readExpiredAttachmentHref = readExpiredAttachmentHref;
2774
3458
  exports.registerAttachmentParser = registerAttachmentParser;
3459
+ exports.repairUrlWhitespace = repairUrlWhitespace;
2775
3460
  exports.safeDecodeURIComponent = safeDecodeURIComponent;
2776
3461
  exports.sanitizeAttachmentLinksForHistory = sanitizeAttachmentLinksForHistory;
2777
3462
  exports.stripFileBlocksFromHistory = stripFileBlocksFromHistory;