bunnyquery 1.6.0 → 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/bunnyquery.js CHANGED
@@ -56,6 +56,9 @@
56
56
  }
57
57
  return _config;
58
58
  }
59
+ function windowedIndexingEnabled() {
60
+ return _config?.windowedIndexing === true;
61
+ }
59
62
  function pollOpt() {
60
63
  const p = _config?.poll;
61
64
  return p === void 0 ? {} : { poll: p };
@@ -149,7 +152,32 @@
149
152
  if (isTextMime(m)) return true;
150
153
  return m.includes("officedocument") || m.includes("opendocument") || m.includes("hwp") || m.includes("epub") || m === "application/msword" || m === "application/vnd.ms-excel" || m === "application/vnd.ms-powerpoint";
151
154
  }
152
- var PAGED_READ_EXTENSIONS = /* @__PURE__ */ new Set(["xls", "xlsx", "xlsm", "ods", "pdf"]);
155
+ var PAGED_READ_EXTENSIONS = /* @__PURE__ */ new Set([
156
+ // grids
157
+ "xls",
158
+ "xlsx",
159
+ "xlsm",
160
+ "ods",
161
+ // delimited text (row-windowed by the layer)
162
+ "csv",
163
+ "tsv",
164
+ "tab",
165
+ // documents
166
+ "pdf",
167
+ "docx",
168
+ "pptx",
169
+ // plain text / data / markup
170
+ "txt",
171
+ "md",
172
+ "markdown",
173
+ "log",
174
+ "json",
175
+ "jsonl",
176
+ "ndjson",
177
+ "xml",
178
+ "yaml",
179
+ "yml"
180
+ ]);
153
181
  function isPagedReadFile(name, mime) {
154
182
  const ext = (name || "").split(".").pop()?.toLowerCase() || "";
155
183
  if (PAGED_READ_EXTENSIONS.has(ext)) return true;
@@ -173,6 +201,16 @@
173
201
  return `{{SKAPI_RENDER::${slug}-${_renderPlaceholderSeq}}}`;
174
202
  }
175
203
  var RENDER_PAGES_PER_WINDOW = 5;
204
+ var _windowPlaceholderSeq = 0;
205
+ function makeWindowPlaceholder(seed) {
206
+ _windowPlaceholderSeq += 1;
207
+ const slug = (seed || "file").replace(/[^a-zA-Z0-9]+/g, "_").slice(-48);
208
+ return `{{SKAPI_WINDOW::${slug}-${_windowPlaceholderSeq}}}`;
209
+ }
210
+ function isWindowedReadFile(name, mime) {
211
+ if (isImageVisionFile(name, mime)) return false;
212
+ return isPagedReadFile(name, mime);
213
+ }
176
214
  function composeUserMessage(text, attachmentUrls) {
177
215
  let composed = text;
178
216
  if (attachmentUrls.length > 0) {
@@ -246,7 +284,7 @@ File attachments: When a user message contains an "Attached files:" section with
246
284
  - 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.
247
285
  - 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.
248
286
  - 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.
249
- 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.
287
+ 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.
250
288
  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.
251
289
  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:
252
290
  \`\`\`filename.csv
@@ -320,32 +358,60 @@ Read this file with the readFileContent tool, using the storage path above - do
320
358
  }
321
359
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
322
360
  }
361
+ var RENDER_FROM_TOKEN = "{{RENDER_FROM}}";
362
+ var WINDOW_CURSOR_TOKEN = RENDER_FROM_TOKEN;
323
363
  function buildIndexingRenderMessage(attachment, placeholder, renderFrom) {
324
364
  const from = Math.max(0, renderFrom || 0);
365
+ if (from > 0) return buildIndexingRenderContinueTemplate(attachment, placeholder, String(from + 1));
366
+ return `A new file has just been uploaded. Index it now.
367
+
368
+ ` + buildRenderMeta(attachment) + `
369
+ This is a PDF. Its pages are delivered to you as RENDERED PAGE IMAGES embedded directly in this message (you do NOT need any tool, URL, or web_fetch to see them). You are shown a WINDOW of pages at a time, starting at page ${from + 1}.
370
+ ` + buildRenderDatafy(placeholder);
371
+ }
372
+ function buildIndexingRenderContinueTemplate(attachment, placeholder, pageLabel = RENDER_FROM_TOKEN) {
325
373
  const src = `src::${attachment.storagePath}`;
326
- const meta = `File metadata:
374
+ return `CONTINUE indexing a PDF whose previous pass did not finish.
375
+
376
+ ` + buildRenderMeta(attachment) + `
377
+ Records for the earlier pages are ALREADY saved (they reference "${src}"). The NEXT window of rendered page images (starting at page ${pageLabel}) is embedded in this message. Datafy each page as before and do NOT re-save pages that are already saved.
378
+ ` + buildRenderDatafy(placeholder);
379
+ }
380
+ function buildRenderMeta(attachment) {
381
+ return `File metadata:
327
382
  - name: ${attachment.name}
328
383
  - storage path: ${attachment.storagePath}
329
384
  ` + (attachment.mime ? `- mime type: ${attachment.mime}
330
385
  ` : "");
331
- const datafy = `
386
+ }
387
+ function buildRenderDatafy(placeholder) {
388
+ return `
332
389
  ${placeholder}
333
390
 
334
391
  LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page call postRecords and save records - one record per row / table entry / line item visible on the page (or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables cell by cell, describe any photos/diagrams). Use the storage path above for the "src::" unique_id.
335
392
 
336
- The note next to the images tells you whether MORE pages remain after this window. If MORE remain: save this window's records and STOP - do NOT write INDEXING_COMPLETE; another pass shows the next window automatically. Only when the note says this is the LAST window (you have seen the whole file) AND everything is saved, end your message with the token INDEXING_COMPLETE.`;
337
- if (from === 0) {
338
- return `A new file has just been uploaded. Index it now.
393
+ Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read the rest of the file and do NOT worry about the pages after this window: if any remain, the next window is rendered and sent to you automatically. Report only the pages you were actually shown - never imply you have seen the whole document.`;
394
+ }
395
+ function buildIndexingWindowMessage(attachment, placeholder, isContinuation, positionLabel) {
396
+ const src = `src::${attachment.storagePath}`;
397
+ const head = isContinuation ? `CONTINUE indexing a file whose previous pass did not finish.
339
398
 
340
- ` + meta + `
341
- This is a PDF. Its pages are delivered to you as RENDERED PAGE IMAGES embedded directly in this message (you do NOT need any tool, URL, or web_fetch to see them). You are shown a WINDOW of pages at a time, starting at page ${from + 1}.
342
- ` + datafy;
343
- }
344
- return `CONTINUE indexing a PDF whose previous pass did not finish.
399
+ ` : `A new file has just been uploaded. Index it now.
400
+
401
+ `;
402
+ const where = isContinuation ? `
403
+ Records for the earlier windows are ALREADY saved (they reference "${src}"). The NEXT window (starting at ${WINDOW_CURSOR_TOKEN}) is embedded below. Do NOT re-save windows that are already saved.
404
+ ` : `
405
+ This file is delivered to you ONE WINDOW at a time, embedded directly in this message. You do NOT need any tool, URL, or web_fetch to read it.
406
+ `;
407
+ return head + buildRenderMeta(attachment) + where + `
408
+ ${placeholder}
345
409
 
346
- ` + meta + `
347
- Records for the earlier pages are ALREADY saved (they reference "${src}"). The NEXT window of rendered page images (starting at page ${from + 1}) is embedded in this message. Datafy each page as before and do NOT re-save pages that are already saved.
348
- ` + datafy;
410
+ 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.
411
+
412
+ 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.
413
+
414
+ 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.`;
349
415
  }
350
416
  function buildIndexingContinueMessage(attachment) {
351
417
  const src = `src::${attachment.storagePath}`;
@@ -455,8 +521,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
455
521
  var EXPIRED_ATTACHMENT_URL_HOST = "_expired_.url";
456
522
  var EXPIRED_ATTACHMENT_URL_ORIGIN = "https://" + EXPIRED_ATTACHMENT_URL_HOST;
457
523
  var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
524
+ var EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = 20 * 60;
525
+ var LINK_REFRESH_WINDOW_MS = (EXPIRED_LINK_REFRESH_EXPIRES_SECONDS - 5 * 60) * 1e3;
458
526
  function createInlineLinkRegex() {
459
- return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]+|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]+|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
527
+ return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
460
528
  }
461
529
  function safeDecodeURIComponent(v) {
462
530
  try {
@@ -516,18 +584,150 @@ Index the REMAINING windows - one record per row/item, looking at any page image
516
584
  return false;
517
585
  }
518
586
  }
587
+ function readExpiredAttachmentHref(href) {
588
+ if (!href) return null;
589
+ try {
590
+ var parsed = new URL(href);
591
+ if (parsed.hostname !== EXPIRED_ATTACHMENT_URL_HOST) return null;
592
+ return normalizeAttachmentPathCandidate(parsed.pathname || "") || null;
593
+ } catch (e) {
594
+ return null;
595
+ }
596
+ }
519
597
  function sanitizeAttachmentLinksForHistory(content, serviceId, forAssistant) {
520
598
  if (!content) return content;
521
599
  if (!forAssistant && content.indexOf("Attached files:") === -1) return content;
522
600
  return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function(_m, label, href) {
523
- if (forAssistant && !isServiceDbAttachmentHref(href, serviceId)) return _m;
601
+ if (!isServiceDbAttachmentHref(href, serviceId)) return _m;
524
602
  var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
525
- var labelPath = normalizeAttachmentPathCandidate(label);
526
- var fullPath = remotePath || labelPath;
527
- if (!fullPath) return forAssistant ? _m : "[" + label + "](" + EXPIRED_ATTACHMENT_URL_ORIGIN + "/file)";
603
+ var fullPath = remotePath || normalizeAttachmentPathCandidate(label);
604
+ if (!fullPath) return _m;
528
605
  return "[" + label + "](" + buildDisplayExpiredAttachmentHref(fullPath, label) + ")";
529
606
  });
530
607
  }
608
+ function isHttpUrlLike(target) {
609
+ return /^https?:\/\//i.test((target || "").trim());
610
+ }
611
+ function repairUrlWhitespace(href) {
612
+ if (!href || !/\s/.test(href)) return href;
613
+ var stripped = href.replace(/\s+/g, "");
614
+ if (/^https?:\/\/[^/\s]+\/download\/[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)?$/i.test(stripped)) return stripped;
615
+ return href.trim().replace(/\s/g, "%20");
616
+ }
617
+ function normalizeTrailingInlineToken(value) {
618
+ if (!value) return value;
619
+ var out = value.replace(/[.,;:!?]+$/, "");
620
+ var trimUnmatched = function(openCh, closeCh) {
621
+ while (out.charAt(out.length - 1) === closeCh) {
622
+ var openCount = (out.match(new RegExp("\\" + openCh, "g")) || []).length;
623
+ var closeCount = (out.match(new RegExp("\\" + closeCh, "g")) || []).length;
624
+ if (closeCount > openCount) out = out.slice(0, -1);
625
+ else break;
626
+ }
627
+ };
628
+ trimUnmatched("(", ")");
629
+ trimUnmatched("[", "]");
630
+ trimUnmatched("{", "}");
631
+ out = out.replace(/[`'"*>]+$/, "");
632
+ return out;
633
+ }
634
+ function classifyInlineLink(full, groups, ctx) {
635
+ var g1 = groups[0], g2 = groups[1], g3 = groups[2], g4 = groups[3], g5 = groups[4], g6 = groups[5];
636
+ var dbHostPrefix = (ctx.dbHostPrefix || "").toLowerCase();
637
+ var fresh = function(expiredHref) {
638
+ return ctx.resolveFreshHref ? ctx.resolveFreshHref(expiredHref) : void 0;
639
+ };
640
+ var isDbHost = function(url) {
641
+ return !!dbHostPrefix && url.toLowerCase().indexOf(dbHostPrefix) === 0;
642
+ };
643
+ var asStoredFile = function(remotePath2, label) {
644
+ if (!remotePath2) return null;
645
+ var expiredHref = buildDisplayExpiredAttachmentHref(remotePath2, label);
646
+ var cached = fresh(expiredHref);
647
+ return {
648
+ part: {
649
+ type: "link",
650
+ label: truncateLabelForDisplay(label),
651
+ fullLabel: label,
652
+ href: cached || expiredHref,
653
+ expired: !cached,
654
+ expiredHref,
655
+ remotePath: remotePath2
656
+ }
657
+ };
658
+ };
659
+ if (g1) {
660
+ var rawPath = normalizeTrailingInlineToken(g1);
661
+ var tail = full.slice(("src::" + rawPath).length);
662
+ var srcIsUrl = isHttpUrlLike(rawPath);
663
+ if (srcIsUrl && !isDbHost(rawPath) && !readExpiredAttachmentHref(rawPath)) {
664
+ return {
665
+ part: { type: "link", label: truncateLabelForDisplay(rawPath), fullLabel: rawPath, href: rawPath, expired: false },
666
+ tail
667
+ };
668
+ }
669
+ var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.serviceId) || normalizeAttachmentPathCandidate(rawPath) : normalizeAttachmentPathCandidate(rawPath));
670
+ var srcBuilt = asStoredFile(srcPath, srcPath);
671
+ return srcBuilt ? { part: srcBuilt.part, tail } : null;
672
+ }
673
+ if (g4 && g5) {
674
+ var dbTarget = /^db:(.+)$/i.exec(g5.trim());
675
+ if (dbTarget) {
676
+ var declared = asStoredFile(normalizeAttachmentPathCandidate(dbTarget[1]), g4);
677
+ if (!declared) return null;
678
+ declared.part.label = truncateLabelForDisplay(g4);
679
+ declared.part.fullLabel = g4;
680
+ return declared;
681
+ }
682
+ if (isHttpUrlLike(g5)) {
683
+ return classifyInlineLink(full, [void 0, g4, repairUrlWhitespace(g5), void 0, void 0, void 0], ctx);
684
+ }
685
+ var trimmedTarget = g5.trim();
686
+ if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedTarget) || trimmedTarget.charAt(0) === "#") {
687
+ return {
688
+ part: { type: "link", label: truncateLabelForDisplay(g4), fullLabel: g4, href: trimmedTarget, expired: false }
689
+ };
690
+ }
691
+ var built = asStoredFile(normalizeAttachmentPathCandidate(g5), g4);
692
+ if (!built) return null;
693
+ built.part.label = truncateLabelForDisplay(g4);
694
+ built.part.fullLabel = g4;
695
+ return built;
696
+ }
697
+ var originalHref = g3 || g6 || "";
698
+ if (!originalHref) return null;
699
+ var urlTail;
700
+ if (!g3 && g6) {
701
+ var trimmedUrl = normalizeTrailingInlineToken(originalHref);
702
+ if (trimmedUrl !== originalHref) urlTail = originalHref.slice(trimmedUrl.length);
703
+ originalHref = trimmedUrl;
704
+ }
705
+ var withTail = function(r) {
706
+ return urlTail ? { part: r.part, tail: urlTail } : r;
707
+ };
708
+ var urlLabel = g2 || originalHref;
709
+ var carried = readExpiredAttachmentHref(originalHref);
710
+ if (carried) {
711
+ var carriedBuilt = asStoredFile(carried, g2 || carried);
712
+ if (carriedBuilt) {
713
+ if (g2) {
714
+ carriedBuilt.part.label = truncateLabelForDisplay(g2);
715
+ carriedBuilt.part.fullLabel = g2;
716
+ }
717
+ return withTail(carriedBuilt);
718
+ }
719
+ }
720
+ if (isServiceDbAttachmentHref(originalHref, ctx.serviceId)) {
721
+ var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.serviceId);
722
+ if (remotePath) {
723
+ var dbBuilt = asStoredFile(remotePath, getExpiredAttachmentVisiblePath(remotePath, urlLabel));
724
+ if (dbBuilt) return withTail(dbBuilt);
725
+ }
726
+ }
727
+ return withTail({
728
+ part: { type: "link", label: truncateLabelForDisplay(urlLabel), fullLabel: urlLabel, href: originalHref, expired: false }
729
+ });
730
+ }
531
731
  function truncateLabelForDisplay(label) {
532
732
  if (!label) return label;
533
733
  if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
@@ -610,24 +810,27 @@ Index the REMAINING windows - one record per row/item, looking at any page image
610
810
  var OPENAI_WEB_SEARCH_EXTERNAL_WEB_ACCESS = true;
611
811
  var MCP_NAME = "BunnyQuery";
612
812
  var DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
613
- var DEFAULT_OPENAI_MODEL = "gpt-5.4";
813
+ var DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
614
814
  var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
615
815
  var clientSecretRequest = (opts) => chatEngineConfig().clientSecretRequest(opts);
616
816
  var getOpenAIImageDetail = (model) => {
617
817
  const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
618
- const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?$/);
818
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
619
819
  if (!match) {
620
820
  return DEFAULT_OPENAI_IMAGE_DETAIL;
621
821
  }
622
822
  const major = Number(match[1]);
623
823
  const minor = match[2] === void 0 ? null : Number(match[2]);
624
- if (major > 5) {
625
- return "original";
626
- }
627
- if (major === 5 && minor !== null && minor >= 4) {
628
- return "original";
824
+ const isVariant = !!match[3];
825
+ const supportsOriginal = major > 5 || major === 5 && minor !== null && minor >= 4;
826
+ if (!supportsOriginal) {
827
+ return DEFAULT_OPENAI_IMAGE_DETAIL;
629
828
  }
630
- return DEFAULT_OPENAI_IMAGE_DETAIL;
829
+ return isVariant ? "high" : "original";
830
+ };
831
+ var getRenderImageDetail = (model) => {
832
+ const detail = getOpenAIImageDetail(model);
833
+ return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? "high" : detail;
631
834
  };
632
835
  var IMAGE_URL_REGEX = /\bhttps?:\/\/[^\s<>"'()\[\]]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s<>"'()\[\]]*)?/gi;
633
836
  function transformContentWithImages(content) {
@@ -872,17 +1075,44 @@ Index the REMAINING windows - one record per row/item, looking at any page image
872
1075
  const visionFile = !parsedContent && isImageVisionFile(attachment.name, attachment.mime);
873
1076
  const renderFrom = Math.max(0, info.renderFrom || 0);
874
1077
  const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : void 0;
1078
+ const renderDetail = platform === "openai" ? getRenderImageDetail(info.model || DEFAULT_OPENAI_MODEL) : void 0;
875
1079
  const skapiRender = visionFile && renderPlaceholder ? {
876
1080
  _skapi_render: [
877
- { path: attachment.storagePath, from: renderFrom, count: RENDER_PAGES_PER_WINDOW, placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime }
1081
+ {
1082
+ path: attachment.storagePath,
1083
+ from: renderFrom,
1084
+ count: RENDER_PAGES_PER_WINDOW,
1085
+ placeholder: renderPlaceholder,
1086
+ name: attachment.name,
1087
+ mime: attachment.mime,
1088
+ detail: renderDetail,
1089
+ auto_continue: true,
1090
+ continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder)
1091
+ }
1092
+ ]
1093
+ } : {};
1094
+ const windowedRead = !visionFile && !parsedContent && windowedIndexingEnabled() && isWindowedReadFile(attachment.name, attachment.mime);
1095
+ const windowPlaceholder = windowedRead ? makeWindowPlaceholder(attachment.storagePath) : void 0;
1096
+ const skapiWindow = windowedRead && windowPlaceholder ? {
1097
+ _skapi_window: [
1098
+ {
1099
+ path: attachment.storagePath,
1100
+ cursor: null,
1101
+ placeholder: windowPlaceholder,
1102
+ name: attachment.name,
1103
+ mime: attachment.mime,
1104
+ kind: "window",
1105
+ auto_continue: true,
1106
+ continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true)
1107
+ }
878
1108
  ]
879
1109
  } : {};
880
- const pagedRead = !visionFile && (continuing || !parsedContent && isPagedReadFile(attachment.name, attachment.mime));
881
- const serverExtract = !visionFile && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
1110
+ const pagedRead = !visionFile && !windowedRead && (continuing || !parsedContent && isPagedReadFile(attachment.name, attachment.mime));
1111
+ const serverExtract = !visionFile && !windowedRead && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
882
1112
  const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : void 0;
883
1113
  const extractContent = serverExtract && placeholder ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }] : void 0;
884
1114
  const skapiExtract = extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
885
- const userMessage = visionFile && renderPlaceholder ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom) : continuing ? buildIndexingContinueMessage(attachment) : buildIndexingUserMessage(
1115
+ const userMessage = visionFile && renderPlaceholder ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom) : windowedRead && windowPlaceholder ? buildIndexingWindowMessage(attachment, windowPlaceholder, false) : continuing ? buildIndexingContinueMessage(attachment) : buildIndexingUserMessage(
886
1116
  attachment,
887
1117
  parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : pagedRead ? { pagedRead: true } : void 0
888
1118
  );
@@ -911,6 +1141,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
911
1141
  max_output_tokens: MAX_TOKENS,
912
1142
  ...skapiExtract,
913
1143
  ...skapiRender,
1144
+ ...skapiWindow,
914
1145
  input: [
915
1146
  { role: "system", content: systemPrompt },
916
1147
  {
@@ -956,6 +1187,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
956
1187
  max_tokens: MAX_TOKENS,
957
1188
  ...skapiExtract,
958
1189
  ...skapiRender,
1190
+ ...skapiWindow,
959
1191
  system: [
960
1192
  {
961
1193
  type: "text",
@@ -1027,9 +1259,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1027
1259
  return "";
1028
1260
  }
1029
1261
  var BG_INDEXING_QUEUE_SUFFIX = "-bg";
1262
+ function isBgIndexingQueue(queueName) {
1263
+ if (typeof queueName !== "string" || !queueName) return false;
1264
+ const prefix = queueName.split("|")[0];
1265
+ const idx = prefix.lastIndexOf(":");
1266
+ const name = idx === -1 ? prefix : prefix.slice(idx + 1);
1267
+ return name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX;
1268
+ }
1030
1269
  var INDEXING_COMPLETE_MARKER = "INDEXING_COMPLETE";
1031
1270
  var MAX_INDEXING_RESUME_PASSES = 6;
1032
- var MAX_VISION_RESUME_PASSES = 40;
1033
1271
  async function getChatHistory(params, fetchOptions) {
1034
1272
  const url = params.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1035
1273
  const p = Object.assign(
@@ -1093,18 +1331,29 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1093
1331
  var serverItemId = item && typeof item.id === "string" && item.id ? item.id : void 0;
1094
1332
  if (userText) {
1095
1333
  var displayContent;
1334
+ var indexFile = void 0;
1096
1335
  if (item._isBgTask) {
1097
1336
  var nameMatch = userText.match(/^- name: (.+)$/m);
1098
1337
  if (nameMatch) {
1099
1338
  var mimeMatch = userText.match(/^- mime type: (.+)$/m);
1100
1339
  var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
1101
1340
  var pathMatch = userText.match(/^- storage path: (.+)$/m);
1341
+ var isContinuePass = userText.indexOf("CONTINUE indexing") === 0;
1102
1342
  displayContent = opts.formatIndexingLabel(
1103
1343
  nameMatch[1].trim(),
1104
1344
  mimeMatch ? mimeMatch[1].trim() : "",
1105
1345
  sizeMatch ? Number(sizeMatch[1]) : null,
1106
- pathMatch ? pathMatch[1].trim() : void 0
1346
+ pathMatch ? pathMatch[1].trim() : void 0,
1347
+ false,
1348
+ isContinuePass
1107
1349
  );
1350
+ indexFile = {
1351
+ name: nameMatch[1].trim(),
1352
+ path: pathMatch ? pathMatch[1].trim() : void 0,
1353
+ mime: mimeMatch ? mimeMatch[1].trim() : void 0,
1354
+ size: sizeMatch ? Number(sizeMatch[1]) : void 0,
1355
+ continued: isContinuePass
1356
+ };
1108
1357
  } else {
1109
1358
  displayContent = userText;
1110
1359
  }
@@ -1116,6 +1365,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1116
1365
  if (isQueued) userMsg.isPendingQueued = true;
1117
1366
  if (isCancelledItem) userMsg.isCancelled = true;
1118
1367
  if (item._isBgTask) userMsg.isBackgroundTask = true;
1368
+ if (indexFile) userMsg._indexFile = indexFile;
1119
1369
  if (item._isOnBgQueue) userMsg._useBgQueue = true;
1120
1370
  if (serverItemId !== void 0) userMsg._serverItemId = serverItemId;
1121
1371
  mapped.push(userMsg);
@@ -1143,6 +1393,91 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1143
1393
  return { messages: mapped, runningItemIds };
1144
1394
  }
1145
1395
 
1396
+ // src/engine/viewport_fill.ts
1397
+ var HISTORY_FILL_SLACK_PX = 64;
1398
+ var MAX_HISTORY_FILL_PAGES = 24;
1399
+ var IDLE_WAIT_STEP_MS = 120;
1400
+ var IDLE_WAIT_MAX_MS = 15e3;
1401
+ async function waitForIdle(opts, stale) {
1402
+ var waited = 0;
1403
+ while (opts.isLoading()) {
1404
+ if (stale() || waited >= IDLE_WAIT_MAX_MS) return false;
1405
+ await new Promise(function(r) {
1406
+ setTimeout(r, IDLE_WAIT_STEP_MS);
1407
+ });
1408
+ waited += IDLE_WAIT_STEP_MS;
1409
+ }
1410
+ return !stale();
1411
+ }
1412
+ async function fillHistoryViewport(opts) {
1413
+ var maxPages = typeof opts.maxPages === "number" ? opts.maxPages : MAX_HISTORY_FILL_PAGES;
1414
+ var stale = function() {
1415
+ return !!(opts.isStale && opts.isStale());
1416
+ };
1417
+ var swallowed = 0;
1418
+ for (var page = 0; page < maxPages; page++) {
1419
+ if (stale() || opts.isEndOfList()) return;
1420
+ if (!await waitForIdle(opts, stale)) return;
1421
+ var satisfied = false;
1422
+ try {
1423
+ satisfied = !!await opts.isSatisfied();
1424
+ } catch {
1425
+ return;
1426
+ }
1427
+ if (satisfied || stale()) return;
1428
+ if (!await waitForIdle(opts, stale)) return;
1429
+ var before = opts.messageCount();
1430
+ var attempted;
1431
+ try {
1432
+ attempted = await opts.fetchOlder();
1433
+ } catch {
1434
+ return;
1435
+ }
1436
+ if (stale()) return;
1437
+ if (attempted === false) {
1438
+ if (++swallowed > 3) return;
1439
+ page--;
1440
+ continue;
1441
+ }
1442
+ if (opts.messageCount() <= before) return;
1443
+ }
1444
+ }
1445
+ function createHistoryFiller(base) {
1446
+ var pending = [];
1447
+ var running = false;
1448
+ async function allSatisfied() {
1449
+ var next = [];
1450
+ for (var i = 0; i < pending.length; i++) {
1451
+ if (!await pending[i]()) next.push(pending[i]);
1452
+ }
1453
+ pending = next;
1454
+ return pending.length === 0;
1455
+ }
1456
+ return {
1457
+ isRunning: function() {
1458
+ return running;
1459
+ },
1460
+ fill: function(isSatisfied) {
1461
+ pending.push(isSatisfied);
1462
+ if (running) return Promise.resolve();
1463
+ running = true;
1464
+ var done = function() {
1465
+ running = false;
1466
+ pending = [];
1467
+ };
1468
+ return fillHistoryViewport({
1469
+ isSatisfied: allSatisfied,
1470
+ isEndOfList: base.isEndOfList,
1471
+ isLoading: base.isLoading,
1472
+ messageCount: base.messageCount,
1473
+ fetchOlder: base.fetchOlder,
1474
+ isStale: base.isStale,
1475
+ maxPages: base.maxPages
1476
+ }).then(done, done);
1477
+ }
1478
+ };
1479
+ }
1480
+
1146
1481
  // src/engine/session.ts
1147
1482
  var _g = typeof globalThis !== "undefined" ? globalThis : {};
1148
1483
  function nowMs() {
@@ -1157,6 +1492,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1157
1492
  cb(nowMs());
1158
1493
  }, 16);
1159
1494
  }
1495
+ function isPollStopped(res) {
1496
+ return !!res && typeof res === "object" && res.status === "stopped";
1497
+ }
1160
1498
  var ChatSession = class {
1161
1499
  constructor(host) {
1162
1500
  this.typewriterQueue = Promise.resolve();
@@ -1177,11 +1515,104 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1177
1515
  };
1178
1516
  this.bgTaskQueue = [];
1179
1517
  this.cancelledServerIds = /* @__PURE__ */ new Set();
1518
+ this.cancelledIndexKeys = /* @__PURE__ */ new Set();
1180
1519
  this.pendingAgentRequests = {};
1181
1520
  this.aiChatHistoryCache = {};
1182
1521
  this.historyItemPolls = /* @__PURE__ */ new Map();
1522
+ this._pauseReasons = /* @__PURE__ */ new Set();
1523
+ this._resuming = false;
1183
1524
  this._lidSeq = 0;
1184
1525
  }
1526
+ /**
1527
+ * Register a live poll so (a) a remount dedupes against it instead of stacking a
1528
+ * SECOND poll on the same item, and (b) pausePolling can stop it.
1529
+ *
1530
+ * `stop` comes from the SDK and may be absent on an older skapi-js, in which case the
1531
+ * poll simply cannot be stopped and is left running — see pausePolling.
1532
+ */
1533
+ _trackPoll(id, kind, p) {
1534
+ var stop = p && typeof p.stop === "function" ? p.stop.bind(p) : void 0;
1535
+ if (!stop) {
1536
+ console.debug("[chat-engine] poll has no stop handle", { id, kind });
1537
+ }
1538
+ this.historyItemPolls.set(id, { kind, stop });
1539
+ return p;
1540
+ }
1541
+ /**
1542
+ * Stop and forget one item's poll. Used after a cancel: the row is either gone
1543
+ * (cancelled while queued) or flagged cancelled (cancelled while running), so
1544
+ * asking about it again only burns requests. Safe when no poll is attached, and
1545
+ * safe on an older skapi-js with no stop handle (the entry is then LEFT in the
1546
+ * map so a later drain cannot stack a second, unstoppable poll on the id).
1547
+ */
1548
+ _stopPoll(id) {
1549
+ var handle = this.historyItemPolls.get(id);
1550
+ if (!handle) return;
1551
+ if (typeof handle.stop !== "function") return;
1552
+ try {
1553
+ handle.stop();
1554
+ } catch (e) {
1555
+ }
1556
+ this.historyItemPolls.delete(id);
1557
+ }
1558
+ /** True while any pause reason is active. */
1559
+ isPollingPaused() {
1560
+ return this._pauseReasons.size > 0;
1561
+ }
1562
+ /**
1563
+ * Stop BACKGROUND polling until resumePolling. Foreground polls (a reply the user is
1564
+ * waiting on) keep running deliberately: their results must still land in the history
1565
+ * cache so resumePendingRequest can render them on return, otherwise a user who sends
1566
+ * a message then navigates away comes back to a permanently stuck "Thinking...".
1567
+ *
1568
+ * Server-side work is untouched; this only stops asking about it. That is safe for
1569
+ * document indexing because the worker drives that loop itself.
1570
+ */
1571
+ pausePolling(reason) {
1572
+ this._pauseReasons.add(reason || "paused");
1573
+ var self = this;
1574
+ var stopped = [];
1575
+ this.historyItemPolls.forEach(function(handle, id) {
1576
+ if (!handle || handle.kind !== "bg") return;
1577
+ if (typeof handle.stop !== "function") return;
1578
+ try {
1579
+ handle.stop();
1580
+ } catch (e) {
1581
+ }
1582
+ stopped.push(id);
1583
+ });
1584
+ stopped.forEach(function(id) {
1585
+ self.historyItemPolls.delete(id);
1586
+ });
1587
+ }
1588
+ /**
1589
+ * Lift a pause reason WITHOUT running the reconcile. For a caller that is about to
1590
+ * reload history anyway (a view remounting), letting resumePolling also reconcile
1591
+ * would race that load and can double-attach.
1592
+ */
1593
+ clearPauseReason(reason) {
1594
+ this._pauseReasons.delete(reason || "paused");
1595
+ }
1596
+ /**
1597
+ * Clear a pause reason and, once none remain, re-attach polling and reconcile.
1598
+ * Deliberately does NOT touch gateRefreshToken: bumping it would silently discard
1599
+ * the results of anything still in flight across the pause.
1600
+ */
1601
+ resumePolling(reason) {
1602
+ this._pauseReasons.delete(reason || "paused");
1603
+ if (this._pauseReasons.size > 0 || this._resuming) return Promise.resolve();
1604
+ if (!this.host.isViewMounted || !this.host.isViewMounted()) return Promise.resolve();
1605
+ var self = this;
1606
+ this._resuming = true;
1607
+ return Promise.resolve().then(function() {
1608
+ self.drainBgTaskQueue();
1609
+ return self.loadHistory(false, self.state.gateRefreshToken);
1610
+ }).catch(function(e) {
1611
+ console.error("[chat-engine] resume polling failed", e);
1612
+ }).then(function() {
1613
+ self._resuming = false;
1614
+ });
1615
+ }
1185
1616
  _newLocalId() {
1186
1617
  this._lidSeq += 1;
1187
1618
  return "lid_" + this._lidSeq;
@@ -1195,29 +1626,97 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1195
1626
  var key = this.getHistoryCacheKey();
1196
1627
  if (!key) return;
1197
1628
  this.aiChatHistoryCache[key] = {
1198
- messages: this.state.messages.slice(),
1629
+ messages: this.state.messages.filter(function(m) {
1630
+ return m._ownerKey === void 0 || m._ownerKey === key;
1631
+ }),
1199
1632
  endOfList: this.state.historyEndOfList,
1200
1633
  startKeyHistory: this.state.historyStartKeyHistory.slice()
1201
1634
  };
1202
1635
  }
1203
- _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls) {
1204
- var id = this.host.getIdentity();
1205
- return platform === "openai" ? callOpenAIWithPublicMcp(prompt, id.serviceId, id.owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, id.serviceId, id.owner, messages, system, model, userId, extractContent, fileUrls);
1636
+ /**
1637
+ * Land a resolved reply in the history cache of a chat that is NOT currently
1638
+ * visible, without touching state.messages. Mirrors the cache-only path in
1639
+ * dispatchAgentRequest: REPLACE the trailing pending "Thinking..." bubble
1640
+ * (append only when there is none), and settle the matching pending user
1641
+ * bubble, so the cached copy never keeps a stuck "Thinking..." that a later
1642
+ * cache-first load would re-render forever.
1643
+ */
1644
+ _applyReplyToCache(key, reply, serverId) {
1645
+ if (!key) return;
1646
+ var existing = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
1647
+ var msgs = existing.messages.slice();
1648
+ var thIdx = -1;
1649
+ for (var i = msgs.length - 1; i >= 0; i--) {
1650
+ var m = msgs[i];
1651
+ if (!m || !m.isPending || m.role !== "assistant" || m.isBackgroundTask) continue;
1652
+ if (serverId && m._serverItemId && m._serverItemId !== serverId) continue;
1653
+ thIdx = i;
1654
+ break;
1655
+ }
1656
+ if (thIdx !== -1) {
1657
+ if (reply._serverItemId === void 0 && msgs[thIdx]._serverItemId !== void 0) reply._serverItemId = msgs[thIdx]._serverItemId;
1658
+ msgs[thIdx] = reply;
1659
+ } else {
1660
+ var dupIdx = -1;
1661
+ if (serverId) {
1662
+ for (var d = msgs.length - 1; d >= 0; d--) {
1663
+ var dm = msgs[d];
1664
+ if (dm && dm.role === "assistant" && dm._serverItemId === serverId) {
1665
+ dupIdx = d;
1666
+ break;
1667
+ }
1668
+ }
1669
+ }
1670
+ if (dupIdx !== -1) msgs[dupIdx] = reply;
1671
+ else msgs.push(reply);
1672
+ }
1673
+ for (var j = 0; j < msgs.length; j++) {
1674
+ var u = msgs[j];
1675
+ if (!u || u.role !== "user" || u.isBackgroundTask) continue;
1676
+ if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
1677
+ if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
1678
+ var settled = { role: "user", content: u.content };
1679
+ if (u._serverItemId !== void 0) settled._serverItemId = u._serverItemId;
1680
+ if (u._ownerKey !== void 0) settled._ownerKey = u._ownerKey;
1681
+ msgs[j] = settled;
1682
+ break;
1683
+ }
1684
+ this.aiChatHistoryCache[key] = {
1685
+ messages: msgs,
1686
+ endOfList: existing.endOfList,
1687
+ startKeyHistory: existing.startKeyHistory
1688
+ };
1689
+ }
1690
+ /**
1691
+ * serviceId/owner are passed explicitly by every caller: a request can be
1692
+ * dispatched after the user moved to another project, and re-reading the live
1693
+ * identity here would silently send the turn to THAT project instead of the
1694
+ * one it was composed for. Falls back to the live read only when a caller
1695
+ * omits them.
1696
+ */
1697
+ _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls, serviceId, owner) {
1698
+ if (serviceId === void 0 || owner === void 0) {
1699
+ var id = this.host.getIdentity();
1700
+ if (serviceId === void 0) serviceId = id.serviceId;
1701
+ if (owner === void 0) owner = id.owner;
1702
+ }
1703
+ return platform === "openai" ? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
1206
1704
  }
1207
1705
  dispatchAgentRequest(params) {
1208
1706
  var self = this;
1209
1707
  var dispatchItemId;
1210
1708
  var sendAndPoll = function() {
1211
1709
  return Promise.resolve(
1212
- self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls)
1710
+ self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
1213
1711
  ).then(function(initial) {
1214
1712
  if (initial && initial.poll && (initial.status === "pending" || initial.status === "running")) {
1215
1713
  if (initial.id) {
1216
1714
  if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
1217
1715
  dispatchItemId = initial.id;
1218
- self.historyItemPolls.set(initial.id, true);
1219
1716
  }
1220
- return initial.poll({ latency: POLL_INTERVAL });
1717
+ var dp = initial.poll({ latency: POLL_INTERVAL });
1718
+ if (initial.id) self._trackPoll(initial.id, "fg", dp);
1719
+ return dp;
1221
1720
  }
1222
1721
  return initial;
1223
1722
  });
@@ -1270,20 +1769,57 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1270
1769
  // composed = clean display text; composedForLlm carries office-extraction
1271
1770
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
1272
1771
  // onto the "-bg" queue so it runs after indexing.
1273
- dispatchComposedMessage(composed, useBgQueue, composedForLlm, extractContent, fileUrls) {
1772
+ dispatchComposedMessage(composed, useBgQueue, composedForLlm, extractContent, fileUrls, pinned) {
1274
1773
  var self = this;
1275
1774
  if (!composed) return;
1276
- var id = this.host.getIdentity();
1775
+ var id = pinned ? pinned.identity : this.host.getIdentity();
1277
1776
  if (id.platform === "none") return;
1278
1777
  var llmComposed = composedForLlm || composed;
1279
- var isQueuedSend = useBgQueue || this.state.sending || this.state.messages.some(function(m) {
1778
+ var key = !id.serviceId ? "" : id.serviceId + "#" + id.platform;
1779
+ var offChat = !!key && key !== this.getHistoryCacheKey();
1780
+ var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function(m) {
1280
1781
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
1281
- });
1782
+ }));
1282
1783
  var aiPlatform = id.platform;
1283
1784
  var aiModel = id.model || void 0;
1284
- var systemPrompt = this.host.buildSystemPrompt();
1785
+ var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
1285
1786
  var userId = id.userId || id.serviceId;
1286
1787
  var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
1788
+ if (offChat) {
1789
+ var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function(m) {
1790
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask;
1791
+ });
1792
+ var offBounded = buildBoundedChatMessages({
1793
+ platform: aiPlatform,
1794
+ model: aiModel,
1795
+ systemPrompt,
1796
+ serviceId: id.serviceId,
1797
+ history: offHistory.concat([{ role: "user", content: llmComposed }])
1798
+ });
1799
+ var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
1800
+ this.aiChatHistoryCache[key] = {
1801
+ messages: offExisting.messages.concat([
1802
+ { role: "user", content: composed, _ownerKey: key },
1803
+ { role: "assistant", content: "", isPending: true, isPendingInProcess: true, _ownerKey: key }
1804
+ ]),
1805
+ endOfList: offExisting.endOfList,
1806
+ startKeyHistory: offExisting.startKeyHistory
1807
+ };
1808
+ this.dispatchAgentRequest({
1809
+ key,
1810
+ serviceId: id.serviceId,
1811
+ owner: id.owner,
1812
+ aiPlatform,
1813
+ aiModel,
1814
+ systemPrompt,
1815
+ text: composed,
1816
+ boundedMessages: offBounded.messages,
1817
+ userId: chatQueue,
1818
+ extractContent,
1819
+ fileUrls
1820
+ });
1821
+ return;
1822
+ }
1287
1823
  if (isQueuedSend) {
1288
1824
  var resolvedHistory = this.state.messages.filter(function(m) {
1289
1825
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask;
@@ -1296,15 +1832,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1296
1832
  history: resolvedHistory.concat([{ role: "user", content: llmComposed }])
1297
1833
  });
1298
1834
  var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true };
1835
+ if (key) queuedBubble._ownerKey = key;
1299
1836
  if (useBgQueue) queuedBubble._useBgQueue = true;
1300
1837
  this.state.messages.push(queuedBubble);
1301
1838
  this.host.notify();
1302
1839
  this.updateHistoryCache();
1303
1840
  this.host.scrollToBottom(true);
1304
- var capturedComposed = composed, capturedPlatform = aiPlatform;
1305
- Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls)).then(function(result) {
1306
- var sendingIdx = self.state.messages.findIndex(function(m) {
1307
- return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user";
1841
+ var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
1842
+ Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner)).then(function(result) {
1843
+ var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function(m) {
1844
+ return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
1308
1845
  });
1309
1846
  var serverId = result && typeof result.id === "string" ? result.id : void 0;
1310
1847
  if (sendingIdx >= 0) {
@@ -1314,26 +1851,27 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1314
1851
  self.host.notify();
1315
1852
  }
1316
1853
  if (result && result.poll && (result.status === "pending" || result.status === "running")) {
1317
- if (serverId) self.historyItemPolls.set(serverId, true);
1318
- return result.poll({ latency: POLL_INTERVAL }).then(function(res) {
1319
- return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId);
1854
+ var qp = result.poll({ latency: POLL_INTERVAL });
1855
+ if (serverId) self._trackPoll(serverId, "fg", qp);
1856
+ return qp.then(function(res) {
1857
+ if (isPollStopped(res)) return;
1858
+ return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey);
1320
1859
  }).catch(function(err) {
1321
- return self.onQueuedSendError(capturedComposed, err, serverId);
1860
+ return self.onQueuedSendError(capturedComposed, err, serverId, capturedKey);
1322
1861
  });
1323
1862
  }
1324
- return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId);
1863
+ return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId, capturedKey);
1325
1864
  }).catch(function(err) {
1326
- return self.onQueuedSendError(capturedComposed, err, void 0);
1865
+ return self.onQueuedSendError(capturedComposed, err, void 0, capturedKey);
1327
1866
  });
1328
1867
  return;
1329
1868
  }
1330
- this.state.messages.push({ role: "user", content: composed });
1331
- this.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true });
1869
+ this.state.messages.push({ role: "user", content: composed, ...key ? { _ownerKey: key } : {} });
1870
+ this.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} });
1332
1871
  this.host.notify();
1333
1872
  this.updateHistoryCache();
1334
1873
  this.state.sending = true;
1335
1874
  this.host.scrollToBottom(true);
1336
- var key = this.getHistoryCacheKey();
1337
1875
  var historyForLlm = this.state.messages.filter(function(m) {
1338
1876
  return !m.isCancelled && !m.isBackgroundTask;
1339
1877
  });
@@ -1367,10 +1905,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1367
1905
  });
1368
1906
  Promise.resolve(run).catch(function() {
1369
1907
  }).then(function() {
1370
- if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
1371
1908
  self.state.sending = false;
1909
+ if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
1372
1910
  return Promise.resolve(self.typewriteLatestReply(key)).then(function() {
1373
- self.host.scrollToBottom(true);
1911
+ self.host.scrollToBottomIfSticky(true);
1374
1912
  });
1375
1913
  });
1376
1914
  }
@@ -1384,10 +1922,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1384
1922
  if (nextIdx === -1) return;
1385
1923
  var existing = this.state.messages[nextIdx];
1386
1924
  var promoted = { role: "user", content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
1925
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
1387
1926
  if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
1927
+ if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
1388
1928
  this.state.messages[nextIdx] = promoted;
1389
1929
  var placeholder = { role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true };
1390
1930
  if (existing._serverItemId !== void 0) placeholder._serverItemId = existing._serverItemId;
1931
+ if (existing._ownerKey !== void 0) placeholder._ownerKey = existing._ownerKey;
1391
1932
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
1392
1933
  this.host.notify();
1393
1934
  }
@@ -1402,15 +1943,22 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1402
1943
  var existing = this.state.messages[nextIdx];
1403
1944
  var promoted = { role: "user", content: existing.content, isPendingInProcess: true };
1404
1945
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
1946
+ if (existing._indexFile) promoted._indexFile = existing._indexFile;
1405
1947
  if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
1948
+ if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
1406
1949
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
1407
1950
  this.state.messages[nextIdx] = promoted;
1408
1951
  var placeholder = { role: "assistant", content: "", isPending: true };
1409
1952
  if (existing._serverItemId !== void 0) placeholder._serverItemId = existing._serverItemId;
1953
+ if (existing._ownerKey !== void 0) placeholder._ownerKey = existing._ownerKey;
1410
1954
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
1411
1955
  this.host.notify();
1412
1956
  }
1413
1957
  resolveQueuedUserBubble(serverId) {
1958
+ var liveKey = this.getHistoryCacheKey();
1959
+ var isLocal = function(m) {
1960
+ return m._ownerKey === void 0 || m._ownerKey === liveKey;
1961
+ };
1414
1962
  var userIdx = -1;
1415
1963
  if (serverId) {
1416
1964
  userIdx = this.state.messages.findIndex(function(m) {
@@ -1419,19 +1967,19 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1419
1967
  }
1420
1968
  if (userIdx === -1) {
1421
1969
  userIdx = this.state.messages.findIndex(function(m) {
1422
- return m.isPendingInProcess && m.role === "user" && !m.isBackgroundTask && !m._useBgQueue;
1970
+ return m.isPendingInProcess && m.role === "user" && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
1423
1971
  });
1424
1972
  }
1425
1973
  if (userIdx === -1) {
1426
1974
  userIdx = this.state.messages.findIndex(function(m) {
1427
- return m.isPendingQueued && m.role === "user" && !m.isBackgroundTask && !m._useBgQueue;
1975
+ return m.isPendingQueued && m.role === "user" && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
1428
1976
  });
1429
1977
  }
1430
1978
  if (serverId && this.cancelledServerIds.has(serverId)) {
1431
1979
  this.cancelledServerIds.delete(serverId);
1432
1980
  if (userIdx >= 0) {
1433
1981
  var ex = this.state.messages[userIdx];
1434
- this.state.messages[userIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
1982
+ this.state.messages[userIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...ex._ownerKey !== void 0 ? { _ownerKey: ex._ownerKey } : {} };
1435
1983
  var thIdx = this.state.messages.findIndex(function(m, i) {
1436
1984
  return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
1437
1985
  });
@@ -1444,6 +1992,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1444
1992
  var exist = this.state.messages[userIdx];
1445
1993
  var repl = { role: "user", content: exist.content };
1446
1994
  if (exist._serverItemId !== void 0) repl._serverItemId = exist._serverItemId;
1995
+ if (exist._ownerKey !== void 0) repl._ownerKey = exist._ownerKey;
1447
1996
  this.state.messages[userIdx] = repl;
1448
1997
  }
1449
1998
  var thinkingIdx = userIdx >= 0 ? this.state.messages.findIndex(function(m, i) {
@@ -1456,8 +2005,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1456
2005
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
1457
2006
  else this.state.messages.push(msg);
1458
2007
  }
1459
- onQueuedSendResponse(_composed, response, platform, serverId) {
2008
+ onQueuedSendResponse(_composed, response, platform, serverId, ownerKey) {
1460
2009
  if (serverId) this.historyItemPolls.delete(serverId);
2010
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
2011
+ var offReply = isErrorResponseBody(response) ? { role: "assistant", content: getErrorMessage(response), isError: true } : { role: "assistant", content: ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim() || "No text response received from AI provider." };
2012
+ this._applyReplyToCache(ownerKey, offReply, serverId);
2013
+ if (serverId) this.cancelledServerIds.delete(serverId);
2014
+ return;
2015
+ }
1461
2016
  var targetIdx = this.resolveQueuedUserBubble(serverId);
1462
2017
  if (targetIdx === void 0) {
1463
2018
  this.host.notify();
@@ -1489,10 +2044,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1489
2044
  this.promoteNextQueuedToRunning();
1490
2045
  this.updateHistoryCache();
1491
2046
  this.host.notify();
1492
- this.host.scrollToBottom(true);
2047
+ this.host.scrollToBottomIfSticky(true);
1493
2048
  }
1494
- onQueuedSendError(_composed, err, serverId) {
2049
+ onQueuedSendError(_composed, err, serverId, ownerKey) {
1495
2050
  if (serverId) this.historyItemPolls.delete(serverId);
2051
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
2052
+ var isGone = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
2053
+ this._applyReplyToCache(ownerKey, isGone ? { role: "assistant", content: "Request was cancelled.", isError: true } : { role: "assistant", content: getErrorMessage(err), isError: true }, serverId);
2054
+ if (serverId) this.cancelledServerIds.delete(serverId);
2055
+ return;
2056
+ }
1496
2057
  var isNotExists = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
1497
2058
  if (isNotExists) {
1498
2059
  var userIdx = serverId ? this.state.messages.findIndex(function(m) {
@@ -1533,7 +2094,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1533
2094
  this.promoteNextQueuedToRunning();
1534
2095
  this.updateHistoryCache();
1535
2096
  this.host.notify();
1536
- this.host.scrollToBottom(true);
2097
+ this.host.scrollToBottomIfSticky(true);
1537
2098
  return;
1538
2099
  }
1539
2100
  var targetIdx = this.resolveQueuedUserBubble(serverId);
@@ -1547,7 +2108,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1547
2108
  this.promoteNextQueuedToRunning();
1548
2109
  this.updateHistoryCache();
1549
2110
  this.host.notify();
1550
- this.host.scrollToBottom(true);
2111
+ this.host.scrollToBottomIfSticky(true);
1551
2112
  }
1552
2113
  cancelQueuedMessage(msg, idx) {
1553
2114
  var self = this;
@@ -1571,6 +2132,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1571
2132
  })).then(function(result) {
1572
2133
  if (result && result.removed) {
1573
2134
  self.cancelledServerIds.add(serverId);
2135
+ self._stopPoll(serverId);
1574
2136
  var qi = self.bgTaskQueue.findIndex(function(e) {
1575
2137
  return e.id === serverId;
1576
2138
  });
@@ -1579,7 +2141,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1579
2141
  return m._serverItemId === serverId && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user";
1580
2142
  });
1581
2143
  if (removeIdx !== -1) {
1582
- self.state.messages[removeIdx] = { role: "user", content: self.state.messages[removeIdx].content, isCancelled: true, _serverItemId: serverId };
2144
+ var wasMsg = self.state.messages[removeIdx];
2145
+ var cancelledMsg = { role: "user", content: wasMsg.content, isCancelled: true, _serverItemId: serverId };
2146
+ if (wasMsg.isBackgroundTask) cancelledMsg.isBackgroundTask = true;
2147
+ if (wasMsg._indexFile) cancelledMsg._indexFile = wasMsg._indexFile;
2148
+ if (wasMsg._useBgQueue) cancelledMsg._useBgQueue = true;
2149
+ if (wasMsg._ownerKey !== void 0) cancelledMsg._ownerKey = wasMsg._ownerKey;
2150
+ self.state.messages[removeIdx] = cancelledMsg;
1583
2151
  var thById = self.state.messages.findIndex(function(m) {
1584
2152
  return m._serverItemId === serverId && m.isPending && m.role === "assistant";
1585
2153
  });
@@ -1616,6 +2184,42 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1616
2184
  }
1617
2185
  });
1618
2186
  }
2187
+ /**
2188
+ * Stop indexing a file, from its collapsed row — every pass at once, not just
2189
+ * the bubble the user happens to see.
2190
+ *
2191
+ * A big file is indexed as a CHAIN of passes, so cancelling only the live one
2192
+ * accomplishes nothing: the next pass is dispatched as soon as it settles.
2193
+ * Three things end the chain:
2194
+ * 1. every queued/running pass of this file is cancelled server-side
2195
+ * (csr-cancel deletes a queued row and flags a running one "cancelled",
2196
+ * which is also the worker's gate for NOT enqueueing the next window);
2197
+ * 2. the file is remembered in cancelledIndexKeys, so the client-driven
2198
+ * resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
2199
+ * 3. any of its passes still sitting in bgTaskQueue is dropped by the next
2200
+ * drain rather than surfacing a fresh "Indexing…" bubble.
2201
+ *
2202
+ * Records already written by the passes that DID run are kept — this stops the
2203
+ * work, it does not undo it.
2204
+ */
2205
+ cancelIndexingGroup(group) {
2206
+ var self = this;
2207
+ if (!group || !group.key) return;
2208
+ var scoped = this.getHistoryCacheKey() + "|" + group.key;
2209
+ this.cancelledIndexKeys.add(scoped);
2210
+ var ids = group.cancellableIds || [];
2211
+ if (!ids.length) {
2212
+ this.host.notify();
2213
+ return;
2214
+ }
2215
+ ids.forEach(function(serverId) {
2216
+ var idx = self.state.messages.findIndex(function(m) {
2217
+ return m._serverItemId === serverId && m.role === "user" && (m.isPendingQueued || m.isPendingInProcess);
2218
+ });
2219
+ if (idx === -1) return;
2220
+ self.cancelQueuedMessage(self.state.messages[idx], idx);
2221
+ });
2222
+ }
1619
2223
  // --- typewriter -------------------------------------------------------
1620
2224
  // Reveal `fullText` into a message bubble at a constant wall-clock RATE
1621
2225
  // (chars/second) driven by requestAnimationFrame, rather than a fixed number
@@ -1802,6 +2406,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1802
2406
  var u = this.state.messages[uIdx];
1803
2407
  var cleaned = { role: "user", content: u.content, _serverItemId: itemId };
1804
2408
  if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
2409
+ if (u._indexFile) cleaned._indexFile = u._indexFile;
1805
2410
  this.state.messages[uIdx] = cleaned;
1806
2411
  }
1807
2412
  // If an immediate-send request for the current cache key is still in flight
@@ -1819,13 +2424,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1819
2424
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
1820
2425
  })) return Promise.resolve();
1821
2426
  this.state.sending = true;
1822
- this.host.scrollToBottom(true);
2427
+ this.host.scrollToBottomIfSticky(true);
1823
2428
  return Promise.resolve(pending).catch(function() {
1824
2429
  }).then(function() {
1825
2430
  if (token !== self.state.gateRefreshToken) return;
1826
2431
  self.state.sending = false;
1827
2432
  return Promise.resolve(self.typewriteLatestReply(key)).then(function() {
1828
- self.host.scrollToBottom(true);
2433
+ self.host.scrollToBottomIfSticky(true);
1829
2434
  });
1830
2435
  });
1831
2436
  }
@@ -1844,8 +2449,17 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1844
2449
  });
1845
2450
  if (idx !== -1) {
1846
2451
  this._clearPendingUserBubble(itemId);
2452
+ var wasBgTask = !!this.state.messages[idx].isBackgroundTask;
1847
2453
  if (isErr) {
1848
2454
  this.state.messages[idx] = { role: "assistant", content: answer, isError: true, _serverItemId: itemId };
2455
+ if (wasBgTask) this.state.messages[idx].isBackgroundTask = true;
2456
+ this.host.notify();
2457
+ this.updateHistoryCache();
2458
+ return;
2459
+ }
2460
+ var text = answer || "No text response received from AI provider.";
2461
+ if (wasBgTask) {
2462
+ this.state.messages[idx] = { role: "assistant", content: text, isBackgroundTask: true, _serverItemId: itemId };
1849
2463
  this.host.notify();
1850
2464
  this.updateHistoryCache();
1851
2465
  return;
@@ -1853,7 +2467,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1853
2467
  var lid = this._newLocalId();
1854
2468
  this.state.messages[idx] = { role: "assistant", content: "", _localId: lid, _serverItemId: itemId };
1855
2469
  this.host.notify();
1856
- this.enqueueTypewrite(idx, answer || "No text response received from AI provider.", lid);
2470
+ this.enqueueTypewrite(idx, text, lid);
1857
2471
  this.updateHistoryCache();
1858
2472
  return;
1859
2473
  }
@@ -1862,25 +2476,124 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1862
2476
  });
1863
2477
  if (userIdx === -1) return;
1864
2478
  var ex = this.state.messages[userIdx];
1865
- this.state.messages[userIdx] = { role: "user", content: ex.content, _serverItemId: itemId };
2479
+ var settledUser = { role: "user", content: ex.content, _serverItemId: itemId };
2480
+ if (ex.isBackgroundTask) settledUser.isBackgroundTask = true;
2481
+ if (ex._indexFile) settledUser._indexFile = ex._indexFile;
2482
+ if (ex._useBgQueue) settledUser._useBgQueue = true;
2483
+ this.state.messages[userIdx] = settledUser;
1866
2484
  if (isErr) {
1867
- this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: answer, isError: true, _serverItemId: itemId });
2485
+ var errReply = { role: "assistant", content: answer, isError: true, _serverItemId: itemId };
2486
+ if (ex.isBackgroundTask) errReply.isBackgroundTask = true;
2487
+ this.state.messages.splice(userIdx + 1, 0, errReply);
2488
+ this.host.notify();
2489
+ this.updateHistoryCache();
2490
+ return;
2491
+ }
2492
+ var text2 = answer || "No text response received from AI provider.";
2493
+ if (ex.isBackgroundTask) {
2494
+ this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: text2, isBackgroundTask: true, _serverItemId: itemId });
1868
2495
  this.host.notify();
1869
2496
  this.updateHistoryCache();
1870
2497
  return;
1871
2498
  }
1872
2499
  var lid2 = this._newLocalId();
1873
- this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: "", _localId: lid2, _serverItemId: itemId });
2500
+ var reply = { role: "assistant", content: "", _localId: lid2, _serverItemId: itemId };
2501
+ this.state.messages.splice(userIdx + 1, 0, reply);
1874
2502
  this.host.notify();
1875
- this.enqueueTypewrite(userIdx + 1, answer || "No text response received from AI provider.", lid2);
2503
+ this.enqueueTypewrite(userIdx + 1, text2, lid2);
1876
2504
  this.updateHistoryCache();
1877
2505
  }
2506
+ /** How a bg task maps onto a collapsed row: the row's own key (storage path
2507
+ * when known, else the filename), scoped to the chat it belongs to. A storage
2508
+ * path is project-relative ("report.xlsx"), and ONE ChatSession serves every
2509
+ * project — unscoped, stopping a file in one project would silently suppress
2510
+ * the same filename's continuations in another. */
2511
+ _indexKeyOf(entry) {
2512
+ if (!entry) return "";
2513
+ var file = entry.storagePath || entry.filename;
2514
+ if (!file) return "";
2515
+ return entry.serviceId + "#" + entry.platform + "|" + file;
2516
+ }
2517
+ /**
2518
+ * Reconcile the bg queue with the files the user has stopped.
2519
+ *
2520
+ * A FIRST pass (no resumePass) is a fresh indexing request — a re-upload, or a
2521
+ * Reindex from the file manager — so it LIFTS the stop: the key is a storage
2522
+ * path, and without this an earlier cancel would silently kill every future
2523
+ * index of the same path. A continuation of a stopped file is dropped instead,
2524
+ * covering the pass that was dispatched in the moment before the cancel landed.
2525
+ */
2526
+ _applyIndexCancellations() {
2527
+ if (!this.cancelledIndexKeys.size) return;
2528
+ for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
2529
+ var entry = this.bgTaskQueue[i];
2530
+ var key = this._indexKeyOf(entry);
2531
+ if (!key || !this.cancelledIndexKeys.has(key)) continue;
2532
+ if (!entry.resumePass) {
2533
+ this.cancelledIndexKeys.delete(key);
2534
+ continue;
2535
+ }
2536
+ this.bgTaskQueue.splice(i, 1);
2537
+ this._stopPoll(entry.id);
2538
+ this._cancelServerItem(entry.id);
2539
+ }
2540
+ }
2541
+ /**
2542
+ * Cancel any live pass of a stopped file that turned up on its own.
2543
+ *
2544
+ * The client is not the only thing that continues a file: for PDFs and (when
2545
+ * windowed indexing is on) text/grid files the WORKER enqueues the next window
2546
+ * itself, and that pass reaches the chat through the history poll, never
2547
+ * through bgTaskQueue. The worker's own gate stops the chain when the running
2548
+ * row is cancelled — but if the row had already finished when the user hit
2549
+ * stop, the next window was queued a moment earlier and still arrives. Stop it
2550
+ * here rather than making the user hit stop again.
2551
+ *
2552
+ * Runs from drainBgTaskQueue, which both clients call after a history load.
2553
+ */
2554
+ _sweepCancelledIndexing() {
2555
+ if (!this.cancelledIndexKeys.size) return;
2556
+ var self = this;
2557
+ var chatKey = this.getHistoryCacheKey();
2558
+ var targets = [];
2559
+ this.state.messages.forEach(function(m, i) {
2560
+ if (!m.isBackgroundTask || m.role !== "user" || !m._serverItemId) return;
2561
+ if (m._cancelling || m.isSendingToServer) return;
2562
+ if (!(m.isPendingQueued || m.isPendingInProcess)) return;
2563
+ var ref = m._indexFile;
2564
+ var file = ref && (ref.path || ref.name);
2565
+ if (!file || !self.cancelledIndexKeys.has(chatKey + "|" + file)) return;
2566
+ targets.push({ msg: m, idx: i });
2567
+ });
2568
+ targets.forEach(function(t) {
2569
+ var idx = self.state.messages.indexOf(t.msg);
2570
+ self.cancelQueuedMessage(t.msg, idx === -1 ? t.idx : idx);
2571
+ });
2572
+ }
2573
+ /** Best-effort server-side cancel of a bg-queue item that has no bubble (so
2574
+ * cancelQueuedMessage, which drives one, has nothing to act on). */
2575
+ _cancelServerItem(serverId) {
2576
+ var id = this.host.getIdentity();
2577
+ if (!serverId || id.platform !== "claude" && id.platform !== "openai") return;
2578
+ var url = id.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
2579
+ Promise.resolve(this.host.cancelRequest({
2580
+ url,
2581
+ method: "POST",
2582
+ id: serverId,
2583
+ queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
2584
+ service: id.serviceId,
2585
+ owner: id.owner
2586
+ })).catch(function() {
2587
+ });
2588
+ }
1878
2589
  // Inject "Indexing: <file>" bubbles for queued bg tasks + attach their polls.
1879
2590
  drainBgTaskQueue() {
1880
2591
  var self = this;
1881
2592
  var id = this.host.getIdentity();
1882
2593
  var svcId = id.serviceId, plat = id.platform;
1883
2594
  if (!svcId || plat === "none" || !this.host.isViewMounted()) return;
2595
+ this._applyIndexCancellations();
2596
+ this._sweepCancelledIndexing();
1884
2597
  var presentIds = {};
1885
2598
  var pendingIds = {};
1886
2599
  this.state.messages.forEach(function(m) {
@@ -1896,39 +2609,70 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1896
2609
  }
1897
2610
  this.bgTaskQueue.forEach(function(entry) {
1898
2611
  if (entry.serviceId !== svcId || entry.platform !== plat) return;
1899
- if (presentIds[entry.id]) return;
1900
- var isRunning = entry.status === "running";
1901
- var userBubble = { role: "user", content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex), isBackgroundTask: true, _serverItemId: entry.id };
1902
- if (isRunning) userBubble.isPendingInProcess = true;
1903
- else userBubble.isPendingQueued = true;
1904
- self.state.messages.push(userBubble);
1905
- if (isRunning) {
1906
- self.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
2612
+ if (!presentIds[entry.id]) {
2613
+ var isRunning = entry.status === "running";
2614
+ var userBubble = {
2615
+ role: "user",
2616
+ content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex, !!entry.resumePass),
2617
+ isBackgroundTask: true,
2618
+ _serverItemId: entry.id,
2619
+ // Structured ref so this live pass groups with the same file's passes
2620
+ // rebuilt from history (see indexing_groups.buildChatDisplayList).
2621
+ _indexFile: {
2622
+ name: entry.filename,
2623
+ path: entry.storagePath,
2624
+ mime: entry.mime,
2625
+ size: entry.size,
2626
+ isReindex: !!entry.isReindex,
2627
+ continued: !!entry.resumePass
2628
+ }
2629
+ };
2630
+ if (isRunning) userBubble.isPendingInProcess = true;
2631
+ else userBubble.isPendingQueued = true;
2632
+ self.state.messages.push(userBubble);
2633
+ if (isRunning) {
2634
+ self.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
2635
+ }
2636
+ presentIds[entry.id] = true;
2637
+ self.host.notify();
2638
+ self.updateHistoryCache();
2639
+ self.host.scrollToBottomIfSticky(false);
1907
2640
  }
1908
- presentIds[entry.id] = true;
1909
- self.host.notify();
1910
- self.updateHistoryCache();
1911
- self.host.scrollToBottom(false);
1912
- if (!self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
1913
- self.historyItemPolls.set(entry.id, true);
2641
+ if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
1914
2642
  var capturedId = entry.id, capturedPlat = plat;
1915
2643
  var capturedEntry = entry;
1916
- entry.poll({ latency: POLL_INTERVAL }).then(function(response) {
2644
+ var wasStopped = false;
2645
+ var bp = entry.poll({ latency: POLL_INTERVAL });
2646
+ self._trackPoll(entry.id, "bg", bp);
2647
+ bp.then(function(response) {
2648
+ if (isPollStopped(response)) {
2649
+ wasStopped = true;
2650
+ return;
2651
+ }
1917
2652
  self.handleHistoryItemResolution(capturedId, response, capturedPlat);
1918
2653
  self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
1919
2654
  }).catch(function(err) {
1920
2655
  self.historyItemPolls.delete(capturedId);
1921
2656
  var isNotExists = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
2657
+ self._clearPendingUserBubble(capturedId);
1922
2658
  var bi = self.state.messages.findIndex(function(m) {
1923
2659
  return m.isPending && m._serverItemId === capturedId;
1924
2660
  });
1925
2661
  if (bi !== -1) {
1926
2662
  if (isNotExists) self.state.messages.splice(bi, 1);
1927
2663
  else self.state.messages[bi] = { role: "assistant", content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId };
1928
- self.host.notify();
1929
- self.updateHistoryCache();
2664
+ } else if (!isNotExists) {
2665
+ var ui = self.state.messages.findIndex(function(m) {
2666
+ return m.role === "user" && m._serverItemId === capturedId;
2667
+ });
2668
+ if (ui !== -1) {
2669
+ self.state.messages.splice(ui + 1, 0, { role: "assistant", content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId });
2670
+ }
1930
2671
  }
2672
+ self.host.notify();
2673
+ self.updateHistoryCache();
1931
2674
  }).then(function() {
2675
+ if (wasStopped) return;
1932
2676
  var qi = self.bgTaskQueue.findIndex(function(q) {
1933
2677
  return q.id === capturedId;
1934
2678
  });
@@ -1939,25 +2683,32 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1939
2683
  this.promoteNextBgQueuedToRunning();
1940
2684
  }
1941
2685
  // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
1942
- // PDF) finished WITHOUT the completion marker, the agent ran out of room before reading
2686
+ // text) finished WITHOUT the completion marker, the agent ran out of room before reading
1943
2687
  // the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
1944
2688
  // already-saved records leave off. Additive + guarded so it never loops forever and
1945
2689
  // never breaks the resolution path.
2690
+ //
2691
+ // VISION files (PDFs rendered to page images) are NOT resumed here: the proxy worker
2692
+ // advances their page window itself, off the renderer's true page count. Driving them
2693
+ // from the browser is what used to lose pages on long documents - the chain lived in tab
2694
+ // memory (a reload or a closed tab ended it), and it stopped whenever the model claimed
2695
+ // completion, which on an 88-page file happened at page 15. Continuing to dispatch here
2696
+ // as well would now double-index every window.
1946
2697
  maybeResumeIndexing(entry, response, platform) {
1947
2698
  var self = this;
1948
2699
  try {
1949
2700
  if (!entry || !entry.storagePath) return;
2701
+ if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
1950
2702
  if (!isPagedReadFile(entry.filename, entry.mime)) return;
2703
+ if (isImageVisionFile(entry.filename, entry.mime)) return;
2704
+ if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
1951
2705
  if (isErrorResponseBody(response)) return;
1952
2706
  var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
1953
2707
  if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return;
1954
2708
  var pass = (entry.resumePass || 0) + 1;
1955
- var isVision = isImageVisionFile(entry.filename, entry.mime);
1956
- var maxPasses = isVision ? MAX_VISION_RESUME_PASSES : MAX_INDEXING_RESUME_PASSES;
1957
- if (pass > maxPasses) return;
2709
+ if (pass > MAX_INDEXING_RESUME_PASSES) return;
1958
2710
  var id = this.host.getIdentity();
1959
2711
  if (!id || id.platform === "none" || id.serviceId !== entry.serviceId) return;
1960
- var renderFrom = isVision ? pass * RENDER_PAGES_PER_WINDOW : void 0;
1961
2712
  notifyAgentContinueIndexing({
1962
2713
  platform: id.platform,
1963
2714
  model: id.model,
@@ -1966,7 +2717,6 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1966
2717
  userId: id.userId || id.serviceId,
1967
2718
  serviceName: id.serviceName,
1968
2719
  serviceDescription: id.serviceDescription,
1969
- renderFrom,
1970
2720
  attachment: {
1971
2721
  name: entry.filename,
1972
2722
  storagePath: entry.storagePath,
@@ -2006,6 +2756,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2006
2756
  loadHistory(fetchMore, token) {
2007
2757
  var self = this;
2008
2758
  var id = this.host.getIdentity();
2759
+ var loadKey = !id.serviceId || id.platform === "none" ? "" : id.serviceId + "#" + id.platform;
2009
2760
  if (token === void 0) token = this.state.gateRefreshToken;
2010
2761
  if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.serviceId) {
2011
2762
  return Promise.resolve();
@@ -2028,9 +2779,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2028
2779
  if (token !== self.state.gateRefreshToken) return;
2029
2780
  var chatList = history && Array.isArray(history.list) ? history.list : [];
2030
2781
  chatList.forEach(function(item) {
2031
- if (typeof item.queue_name === "string" && item.queue_name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX) {
2782
+ if (isBgIndexingQueue(item.queue_name)) {
2032
2783
  var userText = extractLastUserTextFromRequest(item.request_body);
2033
- if (typeof userText === "string" && userText.indexOf("A new file has just been uploaded") === 0) item._isBgTask = true;
2784
+ if (typeof userText === "string" && (userText.indexOf("A new file has just been uploaded") === 0 || userText.indexOf("CONTINUE indexing") === 0)) item._isBgTask = true;
2034
2785
  else item._isOnBgQueue = true;
2035
2786
  }
2036
2787
  });
@@ -2043,6 +2794,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2043
2794
  serviceId: id.serviceId,
2044
2795
  formatIndexingLabel: self.host.formatIndexingLabel
2045
2796
  }).messages;
2797
+ var keptOlderPages = false;
2046
2798
  if (fetchMore) {
2047
2799
  self.state.messages = mapped.concat(self.state.messages);
2048
2800
  } else {
@@ -2053,7 +2805,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2053
2805
  });
2054
2806
  var locallyCancelled = {};
2055
2807
  self.state.messages.forEach(function(m) {
2056
- if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = 1;
2808
+ if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = m;
2809
+ });
2810
+ var inFlightCancel = {};
2811
+ self.state.messages.forEach(function(m) {
2812
+ if (!m._serverItemId) return;
2813
+ if (m._cancelling || m._cancelError) inFlightCancel[m._serverItemId] = m;
2057
2814
  });
2058
2815
  var mappedHasPendingAssistant = mapped.some(function(m) {
2059
2816
  return m.isPending && m.role === "assistant" && !m.isBackgroundTask;
@@ -2062,6 +2819,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2062
2819
  for (var ri = 0; ri < self.state.messages.length; ri++) {
2063
2820
  var mm = self.state.messages[ri];
2064
2821
  if (mm.isBackgroundTask) continue;
2822
+ if (mm._ownerKey !== void 0 && mm._ownerKey !== loadKey) continue;
2065
2823
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
2066
2824
  if (!mm._serverItemId) {
2067
2825
  if (mappedHasPendingAssistant) continue;
@@ -2072,7 +2830,22 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2072
2830
  }
2073
2831
  }
2074
2832
  }
2075
- self.state.messages = mapped;
2833
+ var oldestInPage1 = void 0;
2834
+ mapped.forEach(function(m) {
2835
+ var sid = m._serverItemId;
2836
+ if (typeof sid !== "string") return;
2837
+ if (oldestInPage1 === void 0 || sid < oldestInPage1) oldestInPage1 = sid;
2838
+ });
2839
+ var sharesPage1 = self.state.messages.some(function(m) {
2840
+ return typeof m._serverItemId === "string" && !!serverIds[m._serverItemId];
2841
+ });
2842
+ var retainedOlder = !sharesPage1 || oldestInPage1 === void 0 ? [] : self.state.messages.filter(function(m) {
2843
+ if (typeof m._serverItemId !== "string") return false;
2844
+ if (m._ownerKey !== void 0 && m._ownerKey !== loadKey) return false;
2845
+ return m._serverItemId < oldestInPage1;
2846
+ });
2847
+ keptOlderPages = retainedOlder.length > 0;
2848
+ self.state.messages = keptOlderPages ? retainedOlder.concat(mapped) : mapped;
2076
2849
  rescued.forEach(function(m) {
2077
2850
  self.state.messages.push(m);
2078
2851
  });
@@ -2080,19 +2853,38 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2080
2853
  for (var ci = 0; ci < self.state.messages.length; ci++) {
2081
2854
  var c = self.state.messages[ci];
2082
2855
  if (!c._serverItemId || !locallyCancelled[c._serverItemId] || c.isCancelled) continue;
2083
- self.state.messages[ci] = { role: "user", content: c.content, isCancelled: true, _serverItemId: c._serverItemId };
2856
+ self.state.messages[ci] = {
2857
+ role: "user",
2858
+ content: c.content,
2859
+ isCancelled: true,
2860
+ _serverItemId: c._serverItemId,
2861
+ isBackgroundTask: c.isBackgroundTask,
2862
+ _indexFile: c._indexFile,
2863
+ _useBgQueue: c._useBgQueue,
2864
+ _ownerKey: c._ownerKey
2865
+ };
2084
2866
  if (ci + 1 < self.state.messages.length && self.state.messages[ci + 1].isPending && self.state.messages[ci + 1]._serverItemId === c._serverItemId) {
2085
2867
  self.state.messages.splice(ci + 1, 1);
2086
2868
  }
2087
2869
  }
2088
2870
  }
2871
+ for (var fi = 0; fi < self.state.messages.length; fi++) {
2872
+ var fm = self.state.messages[fi];
2873
+ var was = fm._serverItemId && inFlightCancel[fm._serverItemId];
2874
+ if (!was || fm.isCancelled) continue;
2875
+ if (!(fm.isPendingQueued || fm.isPendingInProcess || fm.isPending)) continue;
2876
+ if (was._cancelling) fm._cancelling = true;
2877
+ if (was._cancelError) fm._cancelError = was._cancelError;
2878
+ }
2089
2879
  }
2090
- self.state.historyEndOfList = !!(history && history.endOfList);
2091
- self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
2092
- var clearedAt = self.host.getClearedAt();
2093
- if (clearedAt && chatList.length > 0) {
2094
- var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
2095
- if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
2880
+ if (!keptOlderPages) {
2881
+ self.state.historyEndOfList = !!(history && history.endOfList);
2882
+ self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
2883
+ var clearedAt = self.host.getClearedAt();
2884
+ if (clearedAt && chatList.length > 0) {
2885
+ var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
2886
+ if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
2887
+ }
2096
2888
  }
2097
2889
  if (self.state.historyRequestToken === token) {
2098
2890
  self.state.loadingHistory = false;
@@ -2106,11 +2898,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2106
2898
  if (!item.poll || !item.id) return;
2107
2899
  if (self.historyItemPolls.has(item.id)) return;
2108
2900
  if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
2109
- self.historyItemPolls.set(item.id, true);
2901
+ if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
2110
2902
  var capturedId = item.id;
2111
2903
  var pp = item.poll({
2112
2904
  latency: POLL_INTERVAL,
2113
2905
  onResponse: function(response) {
2906
+ if (isPollStopped(response)) return;
2114
2907
  self.handleHistoryItemResolution(capturedId, response, platform);
2115
2908
  },
2116
2909
  onError: function(err) {
@@ -2120,18 +2913,25 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2120
2913
  return m.isPending && m._serverItemId === capturedId;
2121
2914
  });
2122
2915
  if (isNotExists) {
2123
- var isBg = aIdx !== -1 ? !!self.state.messages[aIdx].isBackgroundTask : false;
2916
+ var uIdx = self.state.messages.findIndex(function(m) {
2917
+ return m.role === "user" && m._serverItemId === capturedId && !m.isCancelled;
2918
+ });
2919
+ var isBg = aIdx !== -1 && !!self.state.messages[aIdx].isBackgroundTask || uIdx !== -1 && !!self.state.messages[uIdx].isBackgroundTask;
2124
2920
  if (aIdx !== -1) self.state.messages.splice(aIdx, 1);
2125
2921
  if (!isBg) {
2126
- var uIdx = self.state.messages.findIndex(function(m) {
2127
- return m.role === "user" && m._serverItemId === capturedId && !m.isCancelled;
2128
- });
2129
2922
  if (uIdx !== -1) {
2130
2923
  var ex = self.state.messages[uIdx];
2131
2924
  self.state.messages[uIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
2132
2925
  }
2133
2926
  self.cancelledServerIds.delete(capturedId);
2134
2927
  self.promoteNextQueuedToRunning();
2928
+ } else if (uIdx !== -1) {
2929
+ var bex = self.state.messages[uIdx];
2930
+ var bcancelled = { role: "user", content: bex.content, isCancelled: true, isBackgroundTask: true, _serverItemId: bex._serverItemId };
2931
+ if (bex._indexFile) bcancelled._indexFile = bex._indexFile;
2932
+ if (bex._useBgQueue) bcancelled._useBgQueue = true;
2933
+ self.state.messages[uIdx] = bcancelled;
2934
+ self.promoteNextBgQueuedToRunning();
2135
2935
  }
2136
2936
  self.host.notify();
2137
2937
  self.updateHistoryCache();
@@ -2146,12 +2946,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2146
2946
  }
2147
2947
  }
2148
2948
  });
2949
+ self._trackPoll(capturedId, item._isBgTask || item._isOnBgQueue ? "bg" : "fg", pp);
2149
2950
  if (pp && pp.catch) pp.catch(function() {
2150
2951
  });
2151
2952
  });
2152
2953
  self.drainBgTaskQueue();
2153
2954
  }
2154
- if (!fetchMore) return self.host.scrollToBottom();
2955
+ if (!fetchMore) return self.host.scrollToBottomIfSticky();
2155
2956
  }).catch(function(err) {
2156
2957
  console.warn("[chat-engine] getChatHistory failed", err);
2157
2958
  }).then(function() {
@@ -2161,6 +2962,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2161
2962
  self.state.loadingOlderHistory = false;
2162
2963
  if (wasLoading) self.host.notify();
2163
2964
  }
2965
+ if (self.host.onHistoryLoaded) self.host.onHistoryLoaded(!!fetchMore, token);
2164
2966
  });
2165
2967
  }
2166
2968
  // --- attachment upload orchestration ---------------------------------
@@ -2358,11 +3160,194 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2358
3160
  }
2359
3161
  };
2360
3162
 
3163
+ // src/engine/indexing_groups.ts
3164
+ var INDEXING_LABEL_RE = /^(Re)?[Ii]ndexing(\s*\(continuing\))?\s*:?\s+(.+)$/;
3165
+ var LEADING_MD_LINK_RE = /^\[([^\]]+)\]\(([^)]+)\)/;
3166
+ function parseIndexingLabel(content) {
3167
+ if (typeof content !== "string" || !content) return null;
3168
+ var firstLine = content.split("\n")[0].trim();
3169
+ var m = firstLine.match(INDEXING_LABEL_RE);
3170
+ if (!m) return null;
3171
+ var head = m[3].split(" \xB7 ")[0].trim();
3172
+ var link = head.match(LEADING_MD_LINK_RE);
3173
+ var name = link ? link[1].trim() : head;
3174
+ if (!name) return null;
3175
+ return {
3176
+ name,
3177
+ path: link ? link[2].trim() : void 0,
3178
+ continued: !!m[2],
3179
+ isReindex: !!m[1]
3180
+ };
3181
+ }
3182
+ function readFileRef(msg) {
3183
+ var ref = msg && msg._indexFile;
3184
+ if (ref && (ref.path || ref.name)) {
3185
+ return {
3186
+ name: ref.name || ref.path || "",
3187
+ path: ref.path,
3188
+ mime: ref.mime,
3189
+ size: ref.size,
3190
+ isReindex: ref.isReindex,
3191
+ continued: !!ref.continued
3192
+ };
3193
+ }
3194
+ var parsed = parseIndexingLabel(msg && msg.content);
3195
+ if (!parsed) return null;
3196
+ return {
3197
+ name: parsed.name,
3198
+ path: parsed.path,
3199
+ isReindex: parsed.isReindex,
3200
+ continued: parsed.continued
3201
+ };
3202
+ }
3203
+ function isPendingMsg(m) {
3204
+ return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
3205
+ }
3206
+ function buildChatDisplayList(messages, opts) {
3207
+ var list = Array.isArray(messages) ? messages : [];
3208
+ var hasMoreHistory = !!(opts && opts.hasMoreHistory);
3209
+ var groups = {};
3210
+ var order = [];
3211
+ var runOfIndex = new Array(list.length);
3212
+ var runByItemId = {};
3213
+ var keyByName = {};
3214
+ var openRunOfKey = {};
3215
+ var runsOfKey = {};
3216
+ var keyOfRun = {};
3217
+ var runSeq = 0;
3218
+ for (var i = 0; i < list.length; i++) {
3219
+ var msg = list[i];
3220
+ if (!msg || !msg.isBackgroundTask) continue;
3221
+ var runId;
3222
+ var ref = msg.role === "user" ? readFileRef(msg) : null;
3223
+ if (ref) {
3224
+ var key = ref.path || keyByName[ref.name] || ref.name;
3225
+ if (!ref.continued && openRunOfKey[key]) delete openRunOfKey[key];
3226
+ runId = openRunOfKey[key];
3227
+ if (!runId) {
3228
+ runId = "run" + runSeq++;
3229
+ openRunOfKey[key] = runId;
3230
+ keyOfRun[runId] = key;
3231
+ (runsOfKey[key] || (runsOfKey[key] = [])).push(runId);
3232
+ }
3233
+ } else if (msg._serverItemId && runByItemId[msg._serverItemId]) {
3234
+ runId = runByItemId[msg._serverItemId];
3235
+ } else if (msg.role !== "user") {
3236
+ runId = runOfIndex[i - 1];
3237
+ }
3238
+ if (!runId) continue;
3239
+ var g = groups[runId];
3240
+ if (!g) {
3241
+ var fileKey = keyOfRun[runId];
3242
+ g = groups[runId] = {
3243
+ key: fileKey,
3244
+ runKey: runId,
3245
+ // provisional; renumbered newest-first below
3246
+ name: ref ? ref.name : fileKey,
3247
+ path: ref ? ref.path : void 0,
3248
+ mime: ref ? ref.mime : void 0,
3249
+ size: ref ? ref.size : void 0,
3250
+ isReindex: !!(ref && ref.isReindex),
3251
+ members: [],
3252
+ passCount: 0,
3253
+ status: "done",
3254
+ cancellableIds: [],
3255
+ cancelling: false,
3256
+ mayHaveOlder: false,
3257
+ anchorIndex: i
3258
+ };
3259
+ order.push(runId);
3260
+ }
3261
+ if (ref) {
3262
+ if (ref.name) g.name = ref.name;
3263
+ if (ref.path) g.path = ref.path;
3264
+ if (ref.mime) g.mime = ref.mime;
3265
+ if (typeof ref.size === "number") g.size = ref.size;
3266
+ if (ref.isReindex) g.isReindex = true;
3267
+ if (!ref.continued) g.mayHaveOlder = false;
3268
+ g.passCount++;
3269
+ }
3270
+ g.members.push({ msg, index: i });
3271
+ g.anchorIndex = i;
3272
+ runOfIndex[i] = runId;
3273
+ if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
3274
+ if (ref && ref.name) keyByName[ref.name] = g.key;
3275
+ }
3276
+ for (var rk in runsOfKey) {
3277
+ var runIds = runsOfKey[rk];
3278
+ for (var ri = 0; ri < runIds.length; ri++) {
3279
+ var grpR = groups[runIds[ri]];
3280
+ if (!grpR) continue;
3281
+ var first = grpR.members[0];
3282
+ var firstId = first && first.msg && (first.msg._serverItemId || first.msg._localId);
3283
+ grpR.runKey = rk + "#" + (firstId || "n" + ri);
3284
+ }
3285
+ }
3286
+ for (var oi = 0; oi < order.length; oi++) {
3287
+ var grp = groups[order[oi]];
3288
+ var lastSettled = -1;
3289
+ for (var si = 0; si < grp.members.length; si++) {
3290
+ if (!isPendingMsg(grp.members[si].msg)) lastSettled = si;
3291
+ }
3292
+ var active = false;
3293
+ for (var mi = lastSettled + 1; mi < grp.members.length; mi++) {
3294
+ if (isPendingMsg(grp.members[mi].msg)) {
3295
+ active = true;
3296
+ break;
3297
+ }
3298
+ }
3299
+ for (var xi = 0; xi < grp.members.length; xi++) {
3300
+ if (grp.members[xi].msg._cancelling) {
3301
+ grp.cancelling = true;
3302
+ break;
3303
+ }
3304
+ }
3305
+ var seenIds = {};
3306
+ for (var ci = 0; ci < grp.members.length; ci++) {
3307
+ var cm = grp.members[ci].msg;
3308
+ if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
3309
+ if (cm.role !== "user" || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
3310
+ if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
3311
+ if (ci < lastSettled) continue;
3312
+ if (seenIds[cm._serverItemId]) continue;
3313
+ seenIds[cm._serverItemId] = true;
3314
+ grp.cancellableIds.push(cm._serverItemId);
3315
+ }
3316
+ if (active) {
3317
+ grp.status = "active";
3318
+ } else {
3319
+ var last = grp.members[grp.members.length - 1].msg;
3320
+ grp.status = last.isError ? "error" : last.isCancelled ? "cancelled" : "done";
3321
+ }
3322
+ var sawFirstPass = false;
3323
+ for (var pi = 0; pi < grp.members.length; pi++) {
3324
+ var pm = grp.members[pi].msg;
3325
+ if (pm.role !== "user") continue;
3326
+ var pref = readFileRef(pm);
3327
+ if (pref && !pref.continued) {
3328
+ sawFirstPass = true;
3329
+ break;
3330
+ }
3331
+ }
3332
+ grp.mayHaveOlder = !sawFirstPass && hasMoreHistory;
3333
+ }
3334
+ var out = [];
3335
+ for (var j = 0; j < list.length; j++) {
3336
+ var r = runOfIndex[j];
3337
+ if (r === void 0) {
3338
+ out.push({ kind: "message", msg: list[j], index: j });
3339
+ continue;
3340
+ }
3341
+ if (groups[r].anchorIndex === j) out.push({ kind: "indexing", group: groups[r], index: j });
3342
+ }
3343
+ return out;
3344
+ }
3345
+
2361
3346
  // src/index.js
2362
3347
  (function() {
2363
3348
  var MCP_PROD = "https://mcp.broadwayinc.computer";
2364
3349
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
2365
- var BQ_VERSION = "1.6.0" ;
3350
+ var BQ_VERSION = "1.7.0" ;
2366
3351
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
2367
3352
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
2368
3353
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -3574,6 +4559,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3574
4559
  if (CS.composerEl && CS.chatEl && CS.composerEl.parentNode !== CS.chatEl) CS.chatEl.appendChild(CS.composerEl);
3575
4560
  renderMessages();
3576
4561
  scrollToBottom();
4562
+ ensureHistoryFillsViewport();
3577
4563
  }
3578
4564
  function renderAccount() {
3579
4565
  if (!CS.messagesBox) return;
@@ -3941,8 +4927,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3941
4927
  }
3942
4928
  var CS = {
3943
4929
  messages: [],
4930
+ // Rendered .bq-message nodes, indexed BY MESSAGE INDEX (sparse: a message
4931
+ // folded into a collapsed indexing row has no node of its own).
3944
4932
  messageEls: [],
3945
- // parallel rendered .bq-message nodes
4933
+ // Expanded background-indexing rows, keyed by RUN (group.runKey), not by
4934
+ // file: re-indexing a file is a separate row and expands separately.
4935
+ indexGroupsOpen: {},
3946
4936
  messagesBox: null,
3947
4937
  // .bq-messages element
3948
4938
  sending: false,
@@ -4012,14 +5002,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4012
5002
  scrollToBottomIfSticky: function(smooth) {
4013
5003
  return scrollToBottomIfSticky(smooth);
4014
5004
  },
5005
+ // A first page can render shorter than the box (a file's every indexing
5006
+ // pass folds into ONE row), and a box that cannot scroll never fires the
5007
+ // scroll-to-top that is the sole trigger for loading page 2. Page out of
5008
+ // it here — only the view can measure.
5009
+ onHistoryLoaded: function(fetchMore, token) {
5010
+ if (!fetchMore) ensureHistoryFillsViewport(token);
5011
+ },
4015
5012
  cancelRequest: function(opts) {
4016
5013
  return S.skapi.cancelClientSecretRequest(opts);
4017
5014
  },
4018
5015
  refreshSession: function() {
4019
5016
  return refreshSkapiSession();
4020
5017
  },
4021
- formatIndexingLabel: function(name, mime, size, storagePath, reindex) {
4022
- return buildIndexingLabel(name, mime, size, storagePath, reindex);
5018
+ formatIndexingLabel: function(name, mime, size, storagePath, reindex, continued) {
5019
+ return buildIndexingLabel(name, mime, size, storagePath, reindex, continued);
4023
5020
  },
4024
5021
  isViewMounted: function() {
4025
5022
  return !!CS.messagesBox;
@@ -4219,7 +5216,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4219
5216
  if (!CS.messagesBox || CS.chatSettingsOpen) return;
4220
5217
  var el = CS.messagesBox;
4221
5218
  CS.stickToBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 16;
4222
- if (el.scrollTop <= 60) fetchOlderHistoryIfNeeded();
5219
+ if (el.scrollTop <= 60) pageOlderHistoryUntilTaller();
4223
5220
  }
4224
5221
  var _touchStartY = 0;
4225
5222
  function onMessagesWheel(e) {
@@ -4232,23 +5229,6 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4232
5229
  var y = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
4233
5230
  if (y > _touchStartY + 4) CS.stickToBottom = false;
4234
5231
  }
4235
- function normalizeTrailingInlineToken(value) {
4236
- if (!value) return value;
4237
- var out = value.replace(/[.,;:!?]+$/, "");
4238
- var trimUnmatched = function(openCh, closeCh) {
4239
- while (out.charAt(out.length - 1) === closeCh) {
4240
- var openCount = (out.match(new RegExp("\\" + openCh, "g")) || []).length;
4241
- var closeCount = (out.match(new RegExp("\\" + closeCh, "g")) || []).length;
4242
- if (closeCount > openCount) out = out.slice(0, -1);
4243
- else break;
4244
- }
4245
- };
4246
- trimUnmatched("(", ")");
4247
- trimUnmatched("[", "]");
4248
- trimUnmatched("{", "}");
4249
- out = out.replace(/[`'"*>]+$/, "");
4250
- return out;
4251
- }
4252
5232
  function getOrCreateFileHref(filename, body) {
4253
5233
  var key = filename + "\0" + body;
4254
5234
  var existing = fileBlobCache.get(key);
@@ -4289,41 +5269,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4289
5269
  return "<a " + attrs.join(" ") + ">" + escapeHtml(labelText) + "</a>";
4290
5270
  }
4291
5271
  function buildLinkPartFromGroups(full, g1, g2, g3, g4, g5, g6) {
4292
- var dbHostPrefix = "https://db." + hostDomain();
4293
- if (g1) {
4294
- var rawPath = normalizeTrailingInlineToken(g1);
4295
- var consumed = "src::" + rawPath;
4296
- var tail = full.slice(consumed.length);
4297
- var isUrl = /^https?:\/\//i.test(rawPath);
4298
- if (isUrl && /^https:\/\//i.test(rawPath) && rawPath.toLowerCase().indexOf(dbHostPrefix.toLowerCase()) !== 0) {
4299
- return { part: { type: "link", label: truncateLabelForDisplay(rawPath), fullLabel: rawPath, href: rawPath, expired: false }, tail };
5272
+ return classifyInlineLink(full, [g1, g2, g3, g4, g5, g6], {
5273
+ serviceId: S.serviceId,
5274
+ dbHostPrefix: "https://db." + hostDomain(),
5275
+ resolveFreshHref: function(expiredHref) {
5276
+ return refreshedExpiredLinkMap[expiredHref];
4300
5277
  }
4301
- var remotePath = isUrl ? extractRemotePathFromAttachmentHref(rawPath, S.serviceId) || normalizeAttachmentPathCandidate(rawPath) : normalizeAttachmentPathCandidate(rawPath);
4302
- if (!remotePath) return null;
4303
- var expiredHref = buildDisplayExpiredAttachmentHref(remotePath, remotePath);
4304
- var cached = refreshedExpiredLinkMap[expiredHref];
4305
- return { part: { type: "link", label: truncateLabelForDisplay(remotePath), fullLabel: remotePath, href: cached || expiredHref, expired: !cached, expiredHref, remotePath }, tail };
4306
- }
4307
- if (g4 && g5) {
4308
- var rp = normalizeAttachmentPathCandidate(g5);
4309
- if (!rp) return null;
4310
- var eh = buildDisplayExpiredAttachmentHref(rp, rp);
4311
- var c2 = refreshedExpiredLinkMap[eh];
4312
- return { part: { type: "link", label: truncateLabelForDisplay(g4), fullLabel: g4, href: c2 || eh, expired: !c2, expiredHref: eh, remotePath: rp } };
4313
- }
4314
- var originalHref = g3 || g6 || "";
4315
- if (!originalHref) return null;
4316
- if (/^https:\/\//i.test(originalHref) && originalHref.toLowerCase().indexOf(dbHostPrefix.toLowerCase()) !== 0) {
4317
- var plainLabel = g2 || originalHref;
4318
- return { part: { type: "link", label: truncateLabelForDisplay(plainLabel), fullLabel: plainLabel, href: originalHref, expired: false } };
4319
- }
4320
- var rmp = extractRemotePathFromAttachmentHref(originalHref, S.serviceId);
4321
- var fbLabel = g2 || originalHref;
4322
- var ehref = rmp ? buildDisplayExpiredAttachmentHref(rmp, fbLabel) : originalHref;
4323
- var cfresh = refreshedExpiredLinkMap[ehref];
4324
- var expired = !!rmp && !cfresh;
4325
- var fullLabel = rmp ? getExpiredAttachmentVisiblePath(rmp, g2 || originalHref) : g2 || originalHref;
4326
- return { part: { type: "link", label: truncateLabelForDisplay(fullLabel), fullLabel, href: cfresh || ehref, expired, expiredHref: ehref, remotePath: rmp || void 0 } };
5278
+ });
4327
5279
  }
4328
5280
  function parseMsgPartsHtml(content) {
4329
5281
  var placeholderHtml = [];
@@ -4389,9 +5341,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4389
5341
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
4390
5342
  return (n / (1024 * 1024)).toFixed(1) + " MB";
4391
5343
  }
4392
- function buildIndexingLabel(name, mime, size, storagePath, reindex) {
4393
- var extras = [];
5344
+ function buildIndexingLabel(name, mime, size, storagePath, reindex, continued) {
4394
5345
  var nameLabel = storagePath ? "[" + name + "](" + storagePath + ")" : name;
5346
+ if (continued) return "Indexing (continuing) " + nameLabel;
5347
+ var extras = [];
4395
5348
  if (mime) extras.push(mime);
4396
5349
  if (size != null && size !== "" && !isNaN(Number(size))) extras.push(formatBytes(size));
4397
5350
  return (reindex ? "Reindexing: " : "Indexing: ") + nameLabel + (extras.length ? " \xB7 " + extras.join(" \xB7 ") : "");
@@ -4462,16 +5415,17 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4462
5415
  return S.skapi.deleteRecords({ service: S.serviceId, unique_id: "src::" + storagePath }).catch(function() {
4463
5416
  });
4464
5417
  }
4465
- function getTemporaryUrlDb(path, expires) {
4466
- return S.skapi.util.request("get-signed-url", {
5418
+ function getTemporaryUrlDb(path, expires, cdn) {
5419
+ var body = {
4467
5420
  service: S.serviceId,
4468
5421
  owner: S.owner,
4469
5422
  request: "get-db",
4470
5423
  key: path,
4471
- expires: expires,
4472
- contentType: mimeGetType(path) || "application/octet-stream",
4473
- generate_temporary_cdn_url: true
4474
- }, { auth: true, method: "post" }).then(function(res) {
5424
+ expires: expires || ATTACHMENT_URL_EXPIRES_SECONDS,
5425
+ contentType: mimeGetType(path) || "application/octet-stream"
5426
+ };
5427
+ if (cdn !== false) body.generate_temporary_cdn_url = true;
5428
+ return S.skapi.util.request("get-signed-url", body, { auth: true, method: "post" }).then(function(res) {
4475
5429
  var u = typeof res === "string" ? res : res && res.url;
4476
5430
  if (!u) throw new Error("No temporary URL returned.");
4477
5431
  return "https://db." + hostDomain() + "/" + u;
@@ -4975,7 +5929,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4975
5929
  }
4976
5930
  function getPublicTemporaryUrl(remotePath) {
4977
5931
  if (!remotePath) return Promise.reject(new Error("Missing attachment path."));
4978
- return getTemporaryUrlDb(remotePath, ATTACHMENT_URL_EXPIRES_SECONDS);
5932
+ return getTemporaryUrlDb(remotePath, EXPIRED_LINK_REFRESH_EXPIRES_SECONDS, false);
5933
+ }
5934
+ var refreshedLinkExpiryTimer = null;
5935
+ function expireAllRefreshedLinks() {
5936
+ for (var k in refreshedExpiredLinkMap) delete refreshedExpiredLinkMap[k];
5937
+ }
5938
+ function scheduleNextLinkExpiryBoundary() {
5939
+ if (refreshedLinkExpiryTimer) clearTimeout(refreshedLinkExpiryTimer);
5940
+ var now = Date.now();
5941
+ var next = Math.ceil(now / LINK_REFRESH_WINDOW_MS) * LINK_REFRESH_WINDOW_MS;
5942
+ refreshedLinkExpiryTimer = setTimeout(function() {
5943
+ expireAllRefreshedLinks();
5944
+ renderMessages();
5945
+ scheduleNextLinkExpiryBoundary();
5946
+ }, Math.max(1, next - now));
4979
5947
  }
4980
5948
  function fetchFreshHrefForExpiredLink(expiredHref, remotePath) {
4981
5949
  var cached = refreshedExpiredLinkMap[expiredHref];
@@ -4988,6 +5956,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4988
5956
  if (!resolved) return Promise.reject(new Error("Unable to refresh this expired attachment link."));
4989
5957
  return getPublicTemporaryUrl(resolved).then(function(fresh) {
4990
5958
  refreshedExpiredLinkMap[expiredHref] = fresh;
5959
+ scheduleNextLinkExpiryBoundary();
4991
5960
  return fresh;
4992
5961
  });
4993
5962
  })().then(
@@ -5046,15 +6015,77 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5046
6015
  if (key) lsSet(key, String(ts));
5047
6016
  }
5048
6017
  function fetchOlderHistoryIfNeeded() {
5049
- if (CS.loadingHistory || CS.loadingOlderHistory || CS.historyEndOfList) return;
5050
- var prevH = CS.messagesBox ? CS.messagesBox.scrollHeight : 0;
5051
- var prevT = CS.messagesBox ? CS.messagesBox.scrollTop : 0;
5052
- session.loadHistory(true).then(function() {
5053
- if (!CS.messagesBox) return;
5054
- raf2().then(function() {
5055
- if (!CS.messagesBox) return;
5056
- CS.messagesBox.scrollTop = prevT + (CS.messagesBox.scrollHeight - prevH);
5057
- });
6018
+ if (CS.historyEndOfList) return Promise.resolve(true);
6019
+ if (CS.loadingHistory || CS.loadingOlderHistory) return Promise.resolve(false);
6020
+ return session.loadHistory(true).then(function() {
6021
+ return true;
6022
+ });
6023
+ }
6024
+ function messagesBoxCanScroll() {
6025
+ if (!CS.messagesBox || CS.chatSettingsOpen) return true;
6026
+ return CS.messagesBox.scrollHeight - CS.messagesBox.clientHeight > HISTORY_FILL_SLACK_PX;
6027
+ }
6028
+ function topVisibleRowKey() {
6029
+ var box = CS.messagesBox;
6030
+ if (!box) return null;
6031
+ var boxTop = box.getBoundingClientRect().top;
6032
+ var kids = box.children;
6033
+ for (var i = 0; i < kids.length; i++) {
6034
+ var key = kids[i].getAttribute && kids[i].getAttribute("data-row-key");
6035
+ if (!key) continue;
6036
+ if (kids[i].getBoundingClientRect().top - boxTop + kids[i].offsetHeight > 0) return key;
6037
+ }
6038
+ return null;
6039
+ }
6040
+ function contentAboveRow(key) {
6041
+ var box = CS.messagesBox;
6042
+ if (!box) return null;
6043
+ var boxTop = box.getBoundingClientRect().top;
6044
+ var kids = box.children;
6045
+ for (var i = 0; i < kids.length; i++) {
6046
+ if (!kids[i].getAttribute || kids[i].getAttribute("data-row-key") !== key) continue;
6047
+ return kids[i].getBoundingClientRect().top - boxTop + box.scrollTop;
6048
+ }
6049
+ return null;
6050
+ }
6051
+ var _historyFillToken = 0;
6052
+ var _historyFiller = createHistoryFiller({
6053
+ isEndOfList: function() {
6054
+ return !!CS.historyEndOfList;
6055
+ },
6056
+ isLoading: function() {
6057
+ return !!(CS.loadingHistory || CS.loadingOlderHistory);
6058
+ },
6059
+ // The settings panel occupies the messages box and suppresses
6060
+ // renderMessages, so a fill started before it opened must stop.
6061
+ isStale: function() {
6062
+ return _historyFillToken !== CS.gateRefreshToken || !CS.messagesBox || CS.chatSettingsOpen;
6063
+ },
6064
+ messageCount: function() {
6065
+ return CS.messages.length;
6066
+ },
6067
+ fetchOlder: function() {
6068
+ return fetchOlderHistoryIfNeeded();
6069
+ }
6070
+ });
6071
+ function pageOlderHistoryUntil(isSatisfied, token) {
6072
+ if (token === void 0) token = CS.gateRefreshToken;
6073
+ if (token !== CS.gateRefreshToken) return Promise.resolve();
6074
+ _historyFillToken = token;
6075
+ return _historyFiller.fill(function() {
6076
+ return raf2().then(isSatisfied);
6077
+ });
6078
+ }
6079
+ function ensureHistoryFillsViewport(token) {
6080
+ return pageOlderHistoryUntil(messagesBoxCanScroll, token);
6081
+ }
6082
+ function pageOlderHistoryUntilTaller() {
6083
+ var anchorKey = topVisibleRowKey();
6084
+ var before = anchorKey ? contentAboveRow(anchorKey) : null;
6085
+ return pageOlderHistoryUntil(function() {
6086
+ if (!CS.messagesBox || !anchorKey || before === null) return true;
6087
+ var now = contentAboveRow(anchorKey);
6088
+ return now === null || now > before + HISTORY_FILL_SLACK_PX;
5058
6089
  });
5059
6090
  }
5060
6091
  function openClearHistoryModal() {
@@ -5148,6 +6179,102 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5148
6179
  }
5149
6180
  return h("div", { class: cls.join(" "), dataset: { msgIndex: String(idx) } }, bubble);
5150
6181
  }
6182
+ var INDEX_ICON_ACTIVE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 0 1-8.5 8.5 8.5 8.5 0 0 1-7.6-4.7"/><path d="M3.5 12A8.5 8.5 0 0 1 12 3.5a8.5 8.5 0 0 1 7.6 4.7"/><path d="M20 3.6v4.8h-4.8"/><path d="M4 20.4v-4.8h4.8"/></svg>';
6183
+ var INDEX_ICON_DONE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12.5l5 5L20 6.5"/></svg>';
6184
+ var INDEX_ICON_ERROR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5"/><path d="M12 16.6h.01"/></svg>';
6185
+ var INDEX_ICON_CANCELLED = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8.2 8.2l7.6 7.6"/></svg>';
6186
+ function indexGroupIcon(group) {
6187
+ if (group.status === "active") return INDEX_ICON_ACTIVE;
6188
+ if (group.status === "error") return INDEX_ICON_ERROR;
6189
+ if (group.status === "cancelled") return INDEX_ICON_CANCELLED;
6190
+ return INDEX_ICON_DONE;
6191
+ }
6192
+ function indexGroupVerb(group) {
6193
+ if (group.status === "active") return group.isReindex ? "Reindexing" : "Indexing";
6194
+ if (group.status === "error") return "Indexing failed:";
6195
+ if (group.status === "cancelled") return "Indexing cancelled:";
6196
+ return group.isReindex ? "Reindexed" : "Indexed";
6197
+ }
6198
+ function indexGroupLabel(group) {
6199
+ var nameLabel = group.path ? "[" + group.name + "](" + group.path + ")" : group.name;
6200
+ return indexGroupVerb(group) + " " + nameLabel;
6201
+ }
6202
+ function indexGroupCount(group) {
6203
+ if (group.passCount <= 1 && !group.mayHaveOlder) return "";
6204
+ return group.passCount + (group.mayHaveOlder ? "+" : "") + " passes";
6205
+ }
6206
+ function toggleIndexGroup(key) {
6207
+ if (CS.indexGroupsOpen[key]) delete CS.indexGroupsOpen[key];
6208
+ else CS.indexGroupsOpen[key] = true;
6209
+ renderMessages();
6210
+ ensureHistoryFillsViewport();
6211
+ }
6212
+ function buildIndexGroupEl(group, isOpen) {
6213
+ var cls = ["bq-index-group"];
6214
+ if (group.status === "active") cls.push("is-active");
6215
+ if (group.status === "error") cls.push("is-error");
6216
+ if (isOpen) cls.push("is-open");
6217
+ var label = h("span", { class: "bq-index-label", html: parseMsgPartsHtml(indexGroupLabel(group)) });
6218
+ label.addEventListener("click", function(e) {
6219
+ if (e.target && e.target.closest && e.target.closest("a")) e.stopPropagation();
6220
+ onBubbleLinkClick(e);
6221
+ });
6222
+ var cancelBtn = null;
6223
+ if (group.cancellableIds.length || group.cancelling) {
6224
+ cancelBtn = h("button", {
6225
+ class: "bq-index-cancel" + (group.cancelling ? " is-disabled" : ""),
6226
+ type: "button",
6227
+ title: group.cancelling ? "Stopping..." : "Stop indexing this file",
6228
+ "aria-label": "Stop indexing " + group.name,
6229
+ text: group.cancelling ? "Stopping..." : "Stop"
6230
+ });
6231
+ if (group.cancelling) cancelBtn.disabled = true;
6232
+ else cancelBtn.addEventListener("click", function(e) {
6233
+ e.stopPropagation();
6234
+ session.cancelIndexingGroup(group);
6235
+ });
6236
+ cancelBtn.addEventListener("keydown", function(e) {
6237
+ e.stopPropagation();
6238
+ });
6239
+ }
6240
+ var head = h(
6241
+ "div",
6242
+ {
6243
+ class: "bq-index-head",
6244
+ role: "button",
6245
+ tabindex: "0",
6246
+ "aria-expanded": isOpen ? "true" : "false",
6247
+ title: isOpen ? "Hide indexing steps" : "Show indexing steps",
6248
+ onclick: function() {
6249
+ toggleIndexGroup(group.runKey);
6250
+ },
6251
+ onkeydown: function(e) {
6252
+ if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
6253
+ e.preventDefault();
6254
+ toggleIndexGroup(group.runKey);
6255
+ }
6256
+ },
6257
+ h("span", { class: "bq-index-icon", html: indexGroupIcon(group) }),
6258
+ label,
6259
+ indexGroupCount(group) ? h("span", { class: "bq-index-count", text: indexGroupCount(group) }) : null,
6260
+ cancelBtn,
6261
+ h("span", { class: "bq-index-chevron", text: "\u25B6" })
6262
+ );
6263
+ var el = h("div", { class: cls.join(" ") }, head);
6264
+ if (group.cancelError) {
6265
+ el.appendChild(h("div", {
6266
+ class: "bq-index-note is-error",
6267
+ text: "Could not stop this file: " + group.cancelError
6268
+ }));
6269
+ }
6270
+ if (isOpen && group.mayHaveOlder) {
6271
+ el.appendChild(h("div", {
6272
+ class: "bq-index-note",
6273
+ text: "Earlier passes of this file are further back in the conversation. Scroll up to load them."
6274
+ }));
6275
+ }
6276
+ return el;
6277
+ }
5151
6278
  function historyLoadingEl(initial) {
5152
6279
  if (initial) {
5153
6280
  return h(
@@ -5163,9 +6290,54 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5163
6290
  h("span", { class: "bq-loader" })
5164
6291
  );
5165
6292
  }
6293
+ function rowAnchorKey(msg, index) {
6294
+ if (!msg) return null;
6295
+ var id = msg._serverItemId || msg._localId;
6296
+ return id ? "s" + id + ":" + msg.role : "i" + index;
6297
+ }
6298
+ function indexGroupAnchorId(group) {
6299
+ var last = group.members[group.members.length - 1];
6300
+ return last && last.msg && last.msg._serverItemId || "";
6301
+ }
6302
+ function captureScrollAnchor() {
6303
+ var box = CS.messagesBox;
6304
+ if (!box || CS.stickToBottom) return null;
6305
+ var boxTop = box.getBoundingClientRect().top;
6306
+ var kids = box.children;
6307
+ var fallback = null;
6308
+ for (var i = 0; i < kids.length; i++) {
6309
+ if (!kids[i].getAttribute) continue;
6310
+ var key = kids[i].getAttribute("data-row-key");
6311
+ if (!key) continue;
6312
+ var top = kids[i].getBoundingClientRect().top - boxTop;
6313
+ if (top + kids[i].offsetHeight <= 0) continue;
6314
+ var cand = { key, top, scrollTop: box.scrollTop, pos: kids[i].getAttribute("data-row-pos") };
6315
+ if (cand.pos === null) return cand;
6316
+ if (!fallback) fallback = cand;
6317
+ }
6318
+ return fallback || { key: null, top: 0, scrollTop: box.scrollTop };
6319
+ }
6320
+ function restoreScrollAnchor(anchor) {
6321
+ var box = CS.messagesBox;
6322
+ if (!box) return;
6323
+ if (!anchor || CS.stickToBottom) {
6324
+ box.scrollTop = box.scrollHeight;
6325
+ return;
6326
+ }
6327
+ var boxTop = box.getBoundingClientRect().top;
6328
+ var kids = box.children;
6329
+ for (var i = 0; anchor.key && i < kids.length; i++) {
6330
+ if (!kids[i].getAttribute || kids[i].getAttribute("data-row-key") !== anchor.key) continue;
6331
+ if (anchor.pos && kids[i].getAttribute("data-row-pos") !== anchor.pos) break;
6332
+ box.scrollTop += kids[i].getBoundingClientRect().top - boxTop - anchor.top;
6333
+ return;
6334
+ }
6335
+ box.scrollTop = anchor.scrollTop;
6336
+ }
5166
6337
  function renderMessages() {
5167
6338
  if (!CS.messagesBox) return;
5168
6339
  if (CS.chatSettingsOpen) return;
6340
+ var anchor = captureScrollAnchor();
5169
6341
  clear(CS.messagesBox);
5170
6342
  CS.messageEls = [];
5171
6343
  if (CS.loadingOlderHistory) CS.messagesBox.appendChild(historyLoadingEl(false));
@@ -5186,23 +6358,44 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5186
6358
  CS.messagesBox.appendChild(greet);
5187
6359
  return;
5188
6360
  }
5189
- CS.messages.forEach(function(msg, idx) {
5190
- var el = buildMessageEl(msg, idx);
5191
- CS.messageEls.push(el);
6361
+ var rows = buildChatDisplayList(CS.messages, { hasMoreHistory: !CS.historyEndOfList });
6362
+ rows.forEach(function(row) {
6363
+ if (row.kind === "indexing") {
6364
+ var isOpen = !!CS.indexGroupsOpen[row.group.runKey];
6365
+ var groupEl = buildIndexGroupEl(row.group, isOpen);
6366
+ groupEl.setAttribute("data-row-key", "g" + row.group.runKey);
6367
+ groupEl.setAttribute("data-row-pos", indexGroupAnchorId(row.group));
6368
+ CS.messagesBox.appendChild(groupEl);
6369
+ if (!isOpen) return;
6370
+ row.group.members.forEach(function(member) {
6371
+ var pass = buildMessageEl(member.msg, member.index);
6372
+ pass.classList.add("bq-index-pass");
6373
+ pass.setAttribute("data-row-key", rowAnchorKey(member.msg, member.index));
6374
+ CS.messageEls[member.index] = pass;
6375
+ CS.messagesBox.appendChild(pass);
6376
+ });
6377
+ return;
6378
+ }
6379
+ var el = buildMessageEl(row.msg, row.index);
6380
+ el.setAttribute("data-row-key", rowAnchorKey(row.msg, row.index));
6381
+ CS.messageEls[row.index] = el;
5192
6382
  CS.messagesBox.appendChild(el);
5193
6383
  });
6384
+ restoreScrollAnchor(anchor);
5194
6385
  }
5195
6386
  function refreshMessageBubble(idx) {
5196
6387
  if (idx < 0 || idx >= CS.messages.length) return;
5197
6388
  var oldEl = CS.messageEls[idx];
5198
6389
  if (!oldEl || !oldEl.parentNode) return;
5199
6390
  var newEl = buildMessageEl(CS.messages[idx], idx);
6391
+ if (oldEl.classList.contains("bq-index-pass")) newEl.classList.add("bq-index-pass");
5200
6392
  oldEl.parentNode.replaceChild(newEl, oldEl);
5201
6393
  CS.messageEls[idx] = newEl;
5202
6394
  }
5203
6395
  function renderChat() {
5204
6396
  CS.messages = [];
5205
6397
  CS.messageEls = [];
6398
+ CS.indexGroupsOpen = {};
5206
6399
  CS.sending = false;
5207
6400
  CS.typing = false;
5208
6401
  CS.typingAbort = true;
@@ -5620,12 +6813,24 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5620
6813
  S._resizeBound = true;
5621
6814
  window.addEventListener("resize", function() {
5622
6815
  scheduleAttachmentOverflowRecompute();
6816
+ scrollToBottomIfSticky(false);
6817
+ ensureHistoryFillsViewport();
5623
6818
  });
5624
6819
  }
5625
6820
  if (!S._visBound && typeof document !== "undefined" && document.addEventListener) {
5626
6821
  S._visBound = true;
5627
6822
  document.addEventListener("visibilitychange", function() {
5628
- if (document.visibilityState === "visible" && S.user) ensureMcpGrantFresh();
6823
+ if (document.visibilityState === "hidden") {
6824
+ if (session && session.pausePolling) session.pausePolling("hidden");
6825
+ return;
6826
+ }
6827
+ if (document.visibilityState === "visible") {
6828
+ var refreshed = S.user ? ensureMcpGrantFresh() : null;
6829
+ Promise.resolve(refreshed).catch(function() {
6830
+ }).then(function() {
6831
+ if (session && session.resumePolling) session.resumePolling("hidden");
6832
+ });
6833
+ }
5629
6834
  });
5630
6835
  }
5631
6836
  boot();