bunnyquery 1.8.2 → 1.8.4

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
@@ -213,14 +213,15 @@
213
213
  }
214
214
  function composeUserMessage(text, attachmentUrls) {
215
215
  let composed = text;
216
+ let composedForLlm = composed;
216
217
  if (attachmentUrls.length > 0) {
217
218
  const lines = attachmentUrls.map((u) => `- [${u.name}](${u.url})`);
218
219
  composed = `${text}
219
220
 
220
221
  Attached files:
221
222
  ${lines.join("\n")}`;
223
+ composedForLlm = composed;
222
224
  }
223
- let composedForLlm = composed;
224
225
  let extractContent;
225
226
  let fileUrls;
226
227
  if (attachmentUrls.length > 0) {
@@ -237,13 +238,13 @@ ${placeholder}
237
238
  ----- END FILE CONTENT -----`;
238
239
  });
239
240
  extractContent = directives;
240
- composedForLlm = `${composed}
241
+ composedForLlm = `${composedForLlm}
241
242
 
242
243
  Extracted content of attached office files (read inline below; do NOT fetch their URLs):
243
244
 
244
245
  ` + sections.join("\n\n");
245
246
  }
246
- const urlFiles = attachmentUrls.filter((u) => u.url && !isServerExtractable(u.name));
247
+ const urlFiles = [];
247
248
  if (urlFiles.length > 0) {
248
249
  fileUrls = urlFiles.map((u) => ({ path: u.storagePath || u.name, url: u.url }));
249
250
  }
@@ -275,28 +276,39 @@ Extracted content of attached office files (read inline below; do NOT fetch thei
275
276
 
276
277
  // src/engine/prompts/chat_system_prompt.ts
277
278
  function buildChatSystemPrompt(params) {
278
- const { formattedServiceId, serviceName, serviceDescription } = params;
279
+ const { projectId, serviceName, serviceDescription } = params;
279
280
  let systemPrompt = `
280
- You are a dedicated assistant for the project ID: "${formattedServiceId}".
281
+ You are a dedicated assistant for the project ID: "${projectId}".
281
282
  Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage.
282
283
  Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
283
- Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records that usually share a table. So a question about the data almost always spans many records across several files. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "\uC5C6\uC5B4?", "\uD558\uB098\uB3C4 \uC5C6\uB098?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY relevant table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset.
284
+ Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records. ONE file is routinely SPLIT ACROSS SEVERAL TABLES - a summary row in one table, its page or row content in another, its extracted photos and other media in "__MEDIA__", and the indexer often invents a differently-named table on each pass. An index or tag filter matches inside ONE table only and requires table_name: on getRecords, an index or tag sent with table_name but no access_group is auto-filled with access_group "authorized" (where the indexer writes; pass access_group explicitly, including 0, to search another group), while an index or tag WITHOUT table_name FAILS with an error instead of answering, so read the error rather than guessing. Reference is the exception: reference ALONE spans EVERY table and EVERY access group, so getRecords with reference "src::<the file's storage path>" is the one call that returns a whole file's records wherever the indexer put them. Adding table_name narrows it to that table; access_group WITHOUT table_name fails with '"table" is required'; table_name on its own returns that whole table across all access groups. For anything NOT scoped to a single file, call getTables FIRST, run the query once per table that could hold the answer, and combine the results. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "\uC5C6\uC5B4?", "\uD558\uB098\uB3C4 \uC5C6\uB098?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset. If you already answered from one table and then realise another table holds more, do not simply apologise: re-run the sweep and give the complete answer.
284
285
  Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "\uC544\uB2C8\uC694, \uC5C6\uC2B5\uB2C8\uB2E4" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
285
- Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index and tag filters match only exact values or leading prefixes, not substrings, so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
286
+ Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index filters match only exact values, leading prefixes, or trailing suffixes, and tag filters only EXACT whole-tag values - never a partial or interior substring - so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
286
287
  File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
287
288
  - 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.
288
289
  - 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.
289
290
  - 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.
290
- 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.
291
- 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.
292
- 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:
291
+ Stored files and readFileContent: for a file ALREADY in this project's storage, its pages and rows were read at upload time and saved as records, so the database is your best source. Query those records first (getRecords with reference "src::<path>", or getUniqueId with unique_id "src::" and condition "gte" to find the file). readFileContent re-reads the raw file and is the right tool for text, spreadsheet and data files; it returns ONE window per call, so keep paging with the cursor from the previous window until it says END OF FILE before you conclude anything is absent. Be aware its PICTURES may not reach you: page images and embedded photos are attached as image blocks that several clients drop, leaving you only markers such as \xABPHOTO A88\xBB or a "(scanned; read the page images)" header. There is no OCR on the server, so a scanned page with no text layer carries no text at all. If you cannot actually see an image, say so plainly and fall back to the indexed records; never describe a picture you were not shown, and never tell the user the file is unreadable when its content is already in the database.
292
+ 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 - 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 - so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
293
+ File 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; every file extracted out of a document has one too, in table "__MEDIA__" (access_group "authorized"). Present each result as a markdown link as described above. Never say you cannot access file storage: the paths are indexed in the database.
294
+ Showing images: "show me the photo", "\uBCF4\uC5EC\uC918", "display it" is a request for the file's LINK, nothing more. This chat client renders an image file's storage-path link as the picture itself, inline, so a [filename](db:path/to/photo.jpg) link IS the image on screen. Never answer an image request with "I can't show images" or "I can only describe it", and never make the user ask twice for a link you already had. If you have the path, give the link and let the client paint it. The same is true of any file the user asks to see: the link is the answer. Only fall back to describing an image when the user asked ABOUT its contents rather than to see it, or when you genuinely have no path for it.
295
+ Media inside a document is extracted into real files: every embedded PICTURE inside an uploaded document - photos, diagrams, chart images - is pulled out at upload time and saved as its OWN permanent file in this project's storage, in the folder "__MEDIA__/<the document's storage path>/". Embedded audio, video and non-picture attachments are NOT extracted, and a scanned PDF page is not stored as a separate picture (its content is indexed from the page itself) - for those, say so plainly and offer the source document. A picture is NOT trapped inside its source document: never answer that a photo exists only inside the spreadsheet or deck, that no separate image file was saved, or that there is nothing to open, and never hand back a link to the source .xlsx or .pdf when the user asked for a picture inside it.
296
+ Finding an extracted media file: it is INDEXED, and its location is a stored VALUE. Get it by QUERYING, never by constructing a filename.
297
+ RECOGNISE IT BY THE VALUE, NOT THE FIELD NAME. Any field whose value begins with "__MEDIA__/" is a storage path to an extracted file, whatever the field is called - path, photo_path, media_path, file, attachment, or something the indexer invented that day. A record's unique_id beginning "src::__MEDIA__/" marks it as a media record too.
298
+ The reliable query is getRecords with reference "src::<the document's storage path>" - one call, every table, every access group. Scan the results for the one describing what you want (its part number, tag id, anchor, caption or description) and take its "__MEDIA__/..." value. Never let a table guess be the reason you report a file as missing.
299
+ Link it VERBATIM as [caption](db:<the path>). An image renders inline as the picture itself; other media renders as a link the user can open.
300
+ So "show me the photo of part X" is: find the record for that part, take its "__MEDIA__/..." value, link it.
301
+ IF THAT RECORD HAS NO PATH, JOIN ON LOCATION - this needs nothing to have been enriched. Every media record carries data.anchor (the cell or page it was embedded at), plus data.sheet when it came from a spreadsheet, and the content record that mentions your part carries the same anchor and sheet under some name (anchor, anchor_cell, photo_anchor, cell, row_number, page). So: read the anchor and sheet off the content record, query getRecords with reference "src::<the document>", and take the media record whose data.anchor, data.also_at or tags match the anchor, using data.sheet too when both records carry one. Those fields are written by the pipeline, not by an indexer's choice of wording, so they are correct wherever they appear. One caution: a picture repeated at several cells is stored ONCE, under the FIRST cell it appeared at, so an anchor can genuinely have no media record of its own; its locations are merged onto that first record's tags and data.also_at. Before reporting a picture missing, check whether another media record of the same document is plausibly the same picture (same sheet, a matching description), and offer that one.
302
+ THIS IS NOT ONLY ABOUT SPREADSHEET PHOTOS. Treat "show me the diagram in that deck" or "the picture in that PDF" exactly like a photo request: query for the media record, never reconstruct a filename. For embedded video, audio or a non-picture attachment there is no extracted file: say so plainly and offer the source document.
303
+ A document may still have no media record: it was indexed before the "__MEDIA__" table existed, or its format is one whose embedded files are not extracted. Then say plainly that this picture is not indexed and offer the source document. One missing record is never evidence that media is not stored.
304
+ File generation: When the user asks for DATABASE records as a file (CSV, spreadsheet, export, download), call exportRecordsToFile: it writes the rows on the server, keeps them out of your context, and returns a download_url you paste as the link. Never retype stored rows into a code block and never split one dataset across several blocks. For a file you are authoring yourself, or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown, 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 - never base64 or any other encoding. Example for CSV:
293
305
  \`\`\`filename.csv
294
306
  item,qty,total
295
307
  Carrots,55,$38.50
296
308
  Mushrooms,41,$73.80
297
309
  Zucchini,29,$43.50
298
310
  \`\`\`
299
- The same pattern applies to any format \u2014 name the block after the file you intend: \`\`\`my-data.json, \`\`\`index.html, \`\`\`sample.txt, and so on.`;
311
+ The same pattern applies to any format - name the block after the file you intend: \`\`\`my-data.json, \`\`\`index.html, \`\`\`sample.txt, and so on.`;
300
312
  if (serviceDescription) {
301
313
  systemPrompt += `
302
314
  Project name: "${serviceName ?? ""}"
@@ -307,18 +319,26 @@ Project description: """${serviceDescription}"""`;
307
319
 
308
320
  // src/engine/prompts/indexing_system_prompt.ts
309
321
  function buildIndexingSystemPrompt(params) {
310
- const { service, serviceName, serviceDescription } = params;
311
- let systemPrompt = `You are a background indexing agent for project ${service}.
322
+ const { projectId, serviceName, serviceDescription } = params;
323
+ let systemPrompt = `You are a background indexing agent for project ${projectId}.
312
324
  - 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.
313
325
  - Most 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 been extracted on the server and included inline in the user message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read that directly. If the inline content is a "[skapi: ...]" note, the file could not be extracted - index it from its metadata only.
314
- - BIG SPREADSHEETS / TEXT: the inline content may be only the FIRST part of a large file (it can end with a truncation or "more remains" note). For big spreadsheets and big text/data files READ THE FILE WITH THE readFileContent TOOL: it returns the file ONE WINDOW at a time (spreadsheets as coordinate-tagged grid rows, text as a range of characters). Pass the file's storage path. After each window: datafy it into records and SAVE them, THEN if the window says MORE REMAINS call readFileContent again with the cursor it gives you. Repeat until it says END OF FILE, so the WHOLE file is indexed - never stop after the first window. (Do NOT call readFileContent on a PDF - see the next line.)
326
+ - BIG SPREADSHEETS / TEXT: the inline content may be only the FIRST part of a large file (it can end with a truncation or "more remains" note). UNLESS this message already embeds a window of the file (in which case the message tells you not to call readFileContent, and you must not), read big spreadsheets and big text/data files WITH THE readFileContent TOOL: it returns the file ONE WINDOW at a time (spreadsheets as coordinate-tagged grid rows, text as a range of characters). Pass the file's storage path. After each window: datafy it into records and SAVE them, THEN if the window says MORE REMAINS call readFileContent again with the cursor it gives you. Repeat until it says END OF FILE, so the WHOLE file is indexed - never stop after the first window. (Do NOT call readFileContent on a PDF - see the next line.)
315
327
  - PDFs (scanned or not): you do NOT read a PDF with a tool or a URL. Its pages are RENDERED and embedded directly in the user message as IMAGE blocks, a WINDOW of pages at a time. LOOK at the embedded page images and datafy every one. The note beside them tells you whether MORE pages remain: if so, save this window's records and stop (a follow-up pass shows the next window automatically); only when the note says it was the LAST window is the PDF fully seen. Do NOT call readFileContent or web_fetch for a PDF.
316
328
  - VISION: when the message (a readFileContent window, an embedded PDF page, or an inline attachment) includes IMAGES - scanned/rendered PDF pages, or photos embedded in a spreadsheet next to a row/block - LOOK at them and capture what they show as record data (the reading/values in a scanned table, the part/defect/condition visible in a photo). The image IS part of the data; correlate each photo with its labelled block ("PHOTO A3" markers tie a photo to that grid row).
317
- - Whatever the file type, use the file's storage path (the "storage path" metadata line) as the "src::" unique_id - never the inline content or a temporary URL.
318
- - TABULAR data (any spreadsheet - .csv/.tsv/.xlsx/.xls/.ods, or sheet-like rows): you MUST save EVERY data row as its own record (ONE record per row) with that row's actual column values in the record's "data", keyed by the header names, in a dedicated table (e.g. "spreadsheet_rows"). Do NOT summarize, sample only a few rows, or save just file metadata - index the whole sheet, paging through it with readFileContent when it is large. Make MULTIPLE postRecords calls in batches (e.g. 30-50 rows per call) rather than one oversized call. This per-row completeness OVERRIDES brevity. ALSO save one file-level summary record (file name, sheet name(s), column headers, total row count, overall summary) - this is the record that carries the file's "src::" unique_id - and link EVERY per-row record to it via reference (set each row record's reference to that src:: file record; the row records themselves do NOT carry a src:: unique_id). The per-row records AND this reference linkage are BOTH mandatory: the linkage is what lets the whole sheet be found and cleaned up together when the file is re-indexed.
319
- - EPUB / e-books / long-form books (.epub or any book-length prose, provided inline in reading order with chapter headings preserved): you MUST save ONE record per CHAPTER (or, when chapters are unclear, per major section/topic) in a dedicated table (e.g. "book_chapters") - never collapse the whole book into a single record. Each chapter record's "data" must capture the chapter title plus its order/number AND a substantive summary of that chapter's content (key events, arguments, characters, places, concepts, terms, notable quotes). Apply AS MANY relevant tags as possible to EVERY chapter record (characters, locations, themes, topics, key concepts, key terms, dates, named entities) so the book is easy to SEARCH and cross-reference later - this is the whole point. ALSO save one book-level record (title, author, language, overall summary, chapter list / table of contents, genre/subjects) and link each chapter record to it via reference. This per-chapter completeness OVERRIDES brevity; human-readable summaries only, never raw/binary bytes.
320
- - This is a background indexing task: do ALL the MCP saving FIRST, never reply mid-task, and never ask the user questions. Always use the MCP tools to save what you learn - be exhaustive about meaning (and, for tabular data, about every row). SAVE AS YOU GO: persist each window's records before reading the next, so progress is never lost. If the file is so large you cannot finish in one turn, still save everything you have read so far; a follow-up pass will automatically continue from where you stopped. Never store raw or binary bytes (base64, blobs); describe them in human-readable text instead.
321
- - COMPLETION SIGNAL: only when you have fully read and saved the ENTIRE file (for readFileContent files: reached "END OF FILE"; for PDFs: the embedded page-image note said it was the LAST window - with all rows/pages/items saved), end your final message with the token INDEXING_COMPLETE on its own line. If you did NOT finish the whole file (more rows/pages remain), do NOT write that token - leaving it out is how the system knows to run another pass to continue.
329
+ - TRANSCRIBE, DO NOT DESCRIBE. When an image contains ANY text - a label, tag, stamp, form field, serial/part number, handwriting - your FIRST job is to read the characters out and store them VERBATIM, not to describe the scene. A record saying "a red inspection tag with handwritten markings" is worthless: it is unsearchable and every such photo produces the same sentence. Put the characters you can actually read into these EXACT fields, not variations of them: "printed_text" (the pre-printed wording), "handwritten_text" (what a person wrote by hand), and, when you can resolve one, "part_no", "tag_id" and "date". Same reason as the fixed table names: a field called photo_text in one pass and visible_text_notes in the next cannot be queried together. Read PARTIAL values rather than skipping: "500.7402.52__" beats nothing. Only when a character is genuinely unreadable, leave that field null or mark the unreadable span - do NOT invent it, and do NOT replace the whole transcription with a description of what the object looks like. A scene description is a nice extra AFTER the text, never instead of it.
330
+ - IMAGE FILES uploaded as the file itself: if ANY readable character appears ANYWHERE in the image (a label, a stamp, a sign in the background) it counts as an image WITH text - transcribe it per the rule above, and also capture the layout (what appears where) and every entity named. Only a truly text-free image gets description first: a one-line caption, then the objects present with their attributes (type, color, count, condition, position). Either way, save what you extract onto the file's "src::" record with updateRecords, TAG every entity and identifier visible, and INDEX the one number the image offers (a measured value, an amount, a count).
331
+ - Whatever the file type, this file's identity is "src::" + its storage path (the "storage path" metadata line) - never the inline content or a temporary URL. That record ALREADY EXISTS: the upload pipeline creates it in table "file_summaries" (access group "authorized") before indexing starts, so posting it again is rejected as a duplicate unique_id. Reference it from every record you write, and add what you learn to it with updateRecords. If that update unexpectedly reports the record does not exist, post it yourself ONCE with that exact "src::" unique_id (table "file_summaries", access group "authorized") and carry on; this is the ONE exception to the do-NOT-post-the-file-record rules elsewhere in these instructions, because the source identity must never be dropped just because an update failed.
332
+ - REACHABILITY (hard rule): every record you write while indexing this file MUST be reachable from the file's "src::<storage path>" record by following reference - either reference that record directly, or reference something that already reaches it. A record with no reference, or one pointing outside this file's chain, is an ORPHAN: deleting or re-indexing the file removes the reachable records and leaves the orphan behind forever, where it keeps turning up in later answers as stale data. If you create an intermediate record that OTHER records reference (a page record that rows hang off, a sheet or section record), set source.can_remove_referencing_records to true on it; the delete cascade passes a delete through a record only when that record carries the flag OR a unique_id starting "src::" (the file record cascades because its unique_id starts with "src::"; the intermediates you create carry no "src::" id, so they need the flag), and it cascades ONE LEVEL AT A TIME, so EVERY intermediate record in a chain needs its own marker - an unmarked link stops the cascade there and everything below it survives as orphans. When in doubt, reference the file record directly and keep the chain flat.
333
+ - TABULAR data (any spreadsheet - .csv/.tsv/.xlsx/.xls/.ods, or sheet-like rows): you MUST save EVERY data row as its own record (ONE record per row) with that row's actual column values in the record's "data", keyed by the header names, in a table named EXACTLY "spreadsheet_rows". Do NOT summarize, sample only a few rows, or save just file metadata - index the whole sheet, window by window, until it ends. Make MULTIPLE postRecords calls in batches (e.g. 30-50 rows per call) rather than one oversized call. This per-row completeness OVERRIDES brevity. The file-level "src::" record ALREADY EXISTS - the upload pipeline creates it before indexing starts - so do NOT create it. Link EVERY per-row record to it via reference (set each row record's reference to exactly "src::" + the storage path, with NO sheet/window/summary suffix added; the row records themselves do NOT carry a src:: unique_id). Enrich that same record with sheet name(s), column headers and total row count via updateRecords rather than posting another one. The per-row records AND this reference linkage are BOTH mandatory: the linkage is what lets the whole sheet be found and cleaned up together when the file is re-indexed. INDEX each row record on the row's most useful NUMERIC column (named by its header) so rows sort and range-query; when the row has no numeric column, index the grid row number instead. TAG each row record with the sheet name, the file name, and the row's categorical values (a status, a category, a type) - tags are how rows are filtered without scanning the table.
334
+ - ONE RECORD PER GRID ROW, ALWAYS. "Row" means the numbered row of the sheet (R37 is one record), never a visual block, item, section or left/right pair. Sheets that repeat the same columns side by side (an A/B block beside a C/D block, "paired" or "mirrored" layouts) still get ONE record per grid row, holding BOTH sides - suffix the keys to keep them apart (PART_NO_A / PART_NO_B). Collapsing a 16-row window into 2 or 3 "block" records is the single most damaging mistake here: it silently loses most of the cells and makes every later total wrong, because some windows were counted per row and others per block. If a window shows rows R37 to R52, you save records for R37..R52 and the count you report is the number of grid rows you actually wrote.
335
+ - FIXED TABLE NAMES. Never invent a table name for one pass, and never vary the name between passes of the SAME file: that scatters one file's data across tables nobody can enumerate later, so the data is effectively lost even though every save succeeded. Use exactly "spreadsheet_rows" for spreadsheet row records, "book_chapters" for a chapter record, and "file_summaries" for the file-level record (which already exists, so update it and never post it). Embedded photos and other embedded files get NO table of your choosing: their records already exist in table "__MEDIA__", see EXTRACTED MEDIA below. For a content type none of those fit, choose ONE plain descriptive name, use that same name for every pass of the file, and never mint variants of it (inspection_items / item_records / sheet_items / inspection_data are four names for what is one table).
336
+ - EXTRACTED MEDIA: every PICTURE embedded in an uploaded document (photos, diagrams, chart images) is pulled out and saved as a real permanent file under "__MEDIA__/<the document's storage path>/<name>", and a record for each one ALREADY EXISTS in table "__MEDIA__" with unique_id "src::<that path>", reference "src::<the document>", and its path, anchor and sheet already in data. Do NOT create it - the unique_id is taken and your post is rejected. UPDATE it with updateRecords, addressed by that unique_id, adding what the file actually SHOWS plus TAGS for every identifier visible in it (part numbers, tag ids, item names, serial numbers). An update REPLACES the fields you send, so send the existing tags back with your new ones and keep every field already in data (path, anchor, sheet, source, mime, bytes). ONE FILE, ONE RECORD: never also create a photo record in another table. If the update reports that the record does not exist, create it with that same unique_id, reference and data.path - the path must never be lost. Audio and video clips and non-picture attachments are NOT extracted, so never claim a separate file or a "__MEDIA__" record exists for one of those.
337
+ - AUDIO files: transcribe the speech, and capture speakers (named where identifiable), the topics discussed, and timestamps of key moments in the record's data. TAG the language, the audio type (call, meeting, dictation, music), each speaker and every named entity; INDEX the duration in seconds as duration_seconds. VIDEO files: everything audio gets, PLUS transcribe on-screen text verbatim (same transcription discipline as photos) and capture the visual timeline - scene changes and what each scene shows, with timestamps. Same tags as audio plus every entity visible on screen, and INDEX duration_seconds here too. These audio and video rules apply to files UPLOADED AS FILES: the transcript and timeline land on the file's own "src::" record, which already exists. Audio or video embedded inside a document is NOT extracted, so never look for or promise a "__MEDIA__" record for it.
338
+ - EPUB / e-books / long-form books (.epub or any book-length prose, provided inline in reading order with chapter headings preserved): you MUST save ONE record per CHAPTER (or, when chapters are unclear, per major section/topic) in the table "book_chapters" - never collapse the whole book into a single record. INDEX each chapter record on its chapter number (so chapters sort and range-query in order) and include the chapter title among its tags; the record's "data" must capture the chapter title plus its order/number AND a substantive summary of that chapter's content (key events, arguments, characters, places, concepts, terms, notable quotes). Apply AS MANY relevant tags as possible to EVERY chapter record (characters, locations, themes, topics, key concepts, key terms, dates, named entities) so the book is easy to SEARCH and cross-reference later - this is the whole point. ALSO put the book-level facts (title, author, language, overall summary, chapter list / table of contents, genre/subjects) onto the "src::" file record that ALREADY EXISTS in "file_summaries", using updateRecords. Do NOT post a second book-level record, and set every chapter record's reference to exactly "src::" + the storage path. This per-chapter completeness OVERRIDES brevity; human-readable summaries only, never raw/binary bytes.
339
+ - URL SOURCES: when the source being indexed is a URL rather than an uploaded file (a temporary or signed URL that merely DELIVERS an uploaded file's bytes is not a URL source; that file keeps its storage-path identity), its identity is "src::" + the FULL URL INCLUDING the query string (the query string often selects the content, so dropping it collapses different pages into one identity). If no record with that unique_id exists, create it; if the slot is already taken, update that record or reference it - never mint a variant id. For a WEB PAGE: extract everything on it, infer the page's primary entity type when it is not obvious (product, listing, article, profile), TAG that entity type plus the entities on the page, and INDEX the ONE number every entity of that type can be compared by (a price for a product, a date for an article). Any OTHER URL (a file behind a link) is downloaded and indexed under whichever per-type rule above matches its content. When the URL's content offers more index points than one record carries, add reference-linked records reachable from its "src::" record.
340
+ - This is a background indexing task: do ALL the MCP saving FIRST, never reply mid-task, and never ask the user questions. Be exhaustive about meaning (and, for tabular data, about every row). SAVE AS YOU GO: persist each window's records before reading the next, so progress is never lost. If the file is so large you cannot finish in one turn, still save everything you have read so far; a follow-up pass will automatically continue from where you stopped. NEVER store raw or encoded file bytes in ANY field: no base64, no data: URIs, no hex or blob dumps. A long opaque non-human-readable string is not data - replace it with a structured description of what it encodes. If base64 or a data: URI is all you have for something, describe it conceptually and never paste it; if nothing human-readable can be extracted at all, OMIT that record rather than saving noise.
341
+ - COMPLETION SIGNAL: only when YOU paged the file yourself with readFileContent and it reported "END OF FILE", with every row/item saved, end your final message with the token INDEXING_COMPLETE on its own line. If more rows remain, do NOT write that token - leaving it out is how the system knows to run another pass to continue. When the file arrives INSIDE this message one window at a time (an embedded window of rows/text, or rendered PDF page images), you are NOT the one who decides it is finished: the system advances the window off the real page/row count and sends the next pass automatically, so save this window, report what you saved, and never imply you have seen the whole file.
322
342
  - Only AFTER every save is done, send exactly ONE final message summarizing what you indexed - never just "Indexing complete", and never a raw/base64/binary value or a large pasted dump. Keep it to a few factual sentences or a short markdown bullet list covering: the file name, its content type, each table you wrote to with its record/row count and the key columns/fields or topics captured, and anything that could not be extracted. Follow this shape - Indexed <file name> (<content type>): saved <N> records to <table(s)> capturing <key columns/fields or topics>; could not extract: <gaps, or none>.`;
323
343
  if (serviceDescription) {
324
344
  systemPrompt += `
@@ -340,7 +360,7 @@ File metadata:
340
360
  ` : "");
341
361
  if (options?.inlineContent) {
342
362
  return head + `
343
- The file's content was parsed by the client and is provided inline below. Read it directly \u2014 do NOT fetch any URL for this file. Use the storage path above (not this content) for the "src::" unique_id.
363
+ The file's content was parsed by the client and is provided inline below. Read it directly - do NOT fetch any URL for this file. Set every record's reference to exactly "src::" + the storage path above (not this content). That file record already exists, so enrich it with updateRecords rather than posting it.
344
364
 
345
365
  ----- BEGIN FILE CONTENT -----
346
366
  ${options.inlineContent}
@@ -348,7 +368,7 @@ ${options.inlineContent}
348
368
  }
349
369
  if (options?.inlineContentPlaceholder) {
350
370
  return head + `
351
- The file's text content was extracted on the server and is provided inline below. Read it directly \u2014 do NOT fetch any URL for this file. Use the storage path above (not this content) for the "src::" unique_id.
371
+ The file's text content was extracted on the server and is provided inline below. Read it directly - do NOT fetch any URL for this file. Set every record's reference to exactly "src::" + the storage path above (not this content). That file record already exists, so enrich it with updateRecords rather than posting it.
352
372
 
353
373
  ----- BEGIN FILE CONTENT -----
354
374
  ${options.inlineContentPlaceholder}
@@ -356,7 +376,7 @@ ${options.inlineContentPlaceholder}
356
376
  }
357
377
  if (options?.pagedRead) {
358
378
  return head + `
359
- Read this file with the readFileContent tool, using the storage path above - do NOT fetch a URL and do NOT rely on a single sample. readFileContent returns the file ONE WINDOW at a time: spreadsheets as coordinate-tagged grid rows (e.g. 'R4 A:E&I NUMBER | B:E1007'), scanned/large PDFs as rendered PAGE IMAGES, and windows may include embedded photos - LOOK at any images and datafy what they show. Page through EVERY window: for each window SAVE records for its rows/items/pages (postRecords, one record per row/item), THEN if the window says MORE REMAINS call readFileContent again with the cursor it gives you. Repeat until it says END OF FILE, so the WHOLE file is indexed. Do NOT stop after the first window and do NOT just write a summary. Use the storage path above for the "src::" unique_id.` + (attachment.url ? `
379
+ Read this file with the readFileContent tool, using the storage path above - do NOT fetch a URL and do NOT rely on a single sample. readFileContent returns the file ONE WINDOW at a time: spreadsheets as coordinate-tagged grid rows (e.g. 'R4 A:E&I NUMBER | B:E1007'), scanned/large PDFs as rendered PAGE IMAGES, and windows may include embedded photos - LOOK at any images and datafy what they show. Page through EVERY window: for each window SAVE records for its rows/items/pages (postRecords, one record per row/item), THEN if the window says MORE REMAINS call readFileContent again with the cursor it gives you. Repeat until it says END OF FILE, so the WHOLE file is indexed. Do NOT stop after the first window and do NOT just write a summary. Set every record's reference to exactly "src::" + the storage path above; that file record already exists, so enrich it with updateRecords instead of posting it again.` + (attachment.url ? `
360
380
  (A temporary URL is provided ONLY as a fallback if readFileContent fails: ${attachment.url})` : "");
361
381
  }
362
382
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
@@ -391,7 +411,11 @@ Records for the earlier pages are ALREADY saved (they reference "${src}"). The N
391
411
  return `
392
412
  ${placeholder}
393
413
 
394
- 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.
414
+ 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). Set EVERY record's reference to exactly "src::" + the storage path above. That file record ALREADY EXISTS, so do NOT post it, and do NOT give your page records a "src::" unique_id of their own. A record with no reference back to it is an ORPHAN: re-indexing the file deletes the linked records and leaves the orphan behind forever as stale data.
415
+
416
+ Each image is preceded by a label giving its DOCUMENT PAGE number. That label is the page's identity - use it, and ignore any page number PRINTED on the document itself (a scan often restarts its own numbering per section, so a footer reading "PAGE 4 OF 8" routinely disagrees with the real position). Whether a page is one you have already saved is stated in the note above the images - decide from that, never from a printed page number.
417
+
418
+ Transcribe COMPLETELY, not representatively. A table with twenty rows gets twenty records, not a sample of the first few - if a page has more rows than you can save comfortably, still save them all rather than summarising. Where a page carries an embedded text layer it is quoted above that page's image: it is the exact text and should be preferred over reading the pixels, with the image used for layout, tables, stamps and handwriting.
395
419
 
396
420
  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.`;
397
421
  }
@@ -410,9 +434,9 @@ This file is delivered to you ONE WINDOW at a time, embedded directly in this me
410
434
  return head + buildRenderMeta(attachment) + where + `
411
435
  ${placeholder}
412
436
 
413
- 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.
437
+ 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. The file-level record ALREADY EXISTS with unique_id "src::" + the storage path above: do NOT post it (a duplicate unique_id is rejected), enrich it with updateRecords, and link every row/section record to it by reference.
414
438
 
415
- 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.
439
+ If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows. A \xABPHOTO ...\xBB marker in the grid text ties a picture to its row and comes in two forms. \xABPHOTO A88 -> __MEDIA__/...\xBB means the picture at cell A88 is saved as a permanent file at exactly that storage path, and its record in table "__MEDIA__" has unique_id "src::" + that path: UPDATE that record with updateRecords, adding what the picture SHOWS and TAGS for every identifier visible in it (part numbers, tag ids, item names, serial numbers). Do NOT create a duplicate and do NOT add a second photo record in another table: one file, one record. If that update reports the record does not exist, create it ONCE with that same unique_id, reference "src::" + the storage path above, table "__MEDIA__", access group "authorized", and data carrying the path - the path must never be lost. A bare \xABPHOTO A88\xBB marker with no arrow is a picture with no stored path of its own in this window: usually a repeat stored under an earlier anchor, or one too small to keep. NEVER construct a storage path or unique_id for it: find its record, if any, with getRecords reference "src::" + the storage path above, matching the cell against data.anchor or tags, and enrich what you find. The row record stays about its row's cells. Never report that photo contents could not be extracted when images are attached here.
416
440
 
417
441
  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.`;
418
442
  }
@@ -425,11 +449,10 @@ File metadata:
425
449
  - storage path: ${attachment.storagePath}
426
450
  ` + (attachment.mime ? `- mime type: ${attachment.mime}
427
451
  ` : "") + `
428
- Records for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). First call getRecords with reference "${src}" to see how far the previous pass got (the furthest page/row/window already saved). Then call readFileContent with the storage path above and a CURSOR that RESUMES just after that point - do NOT start at the beginning. The cursor is derivable from what you already saved:
429
- - PDF: the cursor is the NUMBER OF PAGES already read (0-based next page). If you saved up to page N, call readFileContent with cursor="N" to get page N+1 onward.
430
- - Spreadsheet: the cursor is "<sheetIndex>:<nextRow>" (0-based sheet index, 1-based row). If you saved up to row R of sheet S, use cursor="S:R+1".
431
- - Text: the cursor is the character offset already read.
432
- Index the REMAINING windows - one record per row/item, looking at any page images or embedded photos - saving as you go until readFileContent reports END OF FILE. Do NOT re-save windows that are already saved. Use the storage path above for the "src::" unique_id. When the ENTIRE file is finally indexed, end your message with the token INDEXING_COMPLETE.`;
452
+ Records for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). First call getRecords with reference "${src}" to see how far the previous pass got (the furthest row/window already saved). The reference ALONE is the whole query: it returns every record written from this file across ALL tables and ALL access groups, so do NOT add table_name or access_group to narrow it. The response is PAGED, so keep fetching pages until it reports there are no more, and take the furthest point from the WHOLE set, never from the first page. Then call readFileContent with the storage path above and a CURSOR that RESUMES just after that point - do NOT start at the beginning. The cursor is derivable from what you already saved:
453
+ - Spreadsheet: the cursor is "<sheetIndex>:<nextRow>" (0-based sheet index, 1-based row). If you saved up to row R of sheet S, use cursor="S:R+1".
454
+ - Text: the cursor is the character offset already read.
455
+ Index the REMAINING windows - one record per row/item, looking at any page images or embedded photos - saving as you go until readFileContent reports END OF FILE. A \xABPHOTO <cell>\xBB marker in a window marks an embedded picture whose extracted file already has a record in table "__MEDIA__": find it with getRecords reference "src::" + the storage path above and match the cell against data.anchor or tags (a repeated picture is stored under its first anchor only), then enrich it with updateRecords. Never create a photo record of your own and never construct a path for one. Do NOT re-save windows that are already saved. Set every record's reference to exactly "src::" + the storage path above (no sheet, window or summary suffix added). That file record already exists, so do NOT post it; enrich it with updateRecords. When the ENTIRE file is finally indexed, end your message with the token INDEXING_COMPLETE.`;
433
456
  }
434
457
 
435
458
  // src/engine/errors.ts
@@ -543,6 +566,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
543
566
  var EXPIRED_ATTACHMENT_URL_ORIGIN = "https://" + EXPIRED_ATTACHMENT_URL_HOST;
544
567
  var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
545
568
  var EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = 20 * 60;
569
+ var PREVIEW_BROWSER_CACHE_SECONDS = 7 * 24 * 60 * 60;
546
570
  var LINK_REFRESH_WINDOW_MS = (EXPIRED_LINK_REFRESH_EXPIRES_SECONDS - 5 * 60) * 1e3;
547
571
  function createInlineLinkRegex() {
548
572
  return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
@@ -562,7 +586,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
562
586
  function normalizeAttachmentPathCandidate(value) {
563
587
  return safeDecodeURIComponent((value || "").trim()).replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/");
564
588
  }
565
- function extractRemotePathFromAttachmentHref(href, serviceId) {
589
+ function extractRemotePathFromAttachmentHref(href, projectId) {
566
590
  try {
567
591
  var parsed = new URL(href);
568
592
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
@@ -570,7 +594,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
570
594
  var segs = path.split("/").filter(Boolean);
571
595
  if (!segs.length) return null;
572
596
  var HEX = /^[a-f0-9]{32,}$/i;
573
- var sid = serviceId || "";
597
+ var sid = projectId || "";
574
598
  var start = 0;
575
599
  while (start < segs.length) {
576
600
  var seg = segs[start];
@@ -594,13 +618,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
594
618
  function buildDisplayExpiredAttachmentHref(remotePath, fallback) {
595
619
  return EXPIRED_ATTACHMENT_URL_ORIGIN + "/" + encodePathSegments(getExpiredAttachmentVisiblePath(remotePath, fallback));
596
620
  }
597
- function isServiceDbAttachmentHref(href, serviceId) {
598
- if (!serviceId) return false;
621
+ function isServiceDbAttachmentHref(href, projectId) {
622
+ if (!projectId) return false;
599
623
  try {
600
624
  var parsed = new URL(href);
601
625
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
602
626
  var segs = normalizeAttachmentPathCandidate(parsed.pathname || "").split("/").filter(Boolean);
603
- return segs.length > 0 && segs[0] === serviceId;
627
+ return segs.length > 0 && segs[0] === projectId;
604
628
  } catch (e) {
605
629
  return false;
606
630
  }
@@ -615,12 +639,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
615
639
  return null;
616
640
  }
617
641
  }
618
- function sanitizeAttachmentLinksForHistory(content, serviceId, forAssistant) {
642
+ function sanitizeAttachmentLinksForHistory(content, projectId, forAssistant) {
619
643
  if (!content) return content;
620
644
  if (!forAssistant && content.indexOf("Attached files:") === -1) return content;
621
645
  return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function(_m, label, href) {
622
- if (!isServiceDbAttachmentHref(href, serviceId)) return _m;
623
- var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
646
+ if (!isServiceDbAttachmentHref(href, projectId)) return _m;
647
+ var remotePath = extractRemotePathFromAttachmentHref(href, projectId);
624
648
  var fullPath = remotePath || normalizeAttachmentPathCandidate(label);
625
649
  if (!fullPath) return _m;
626
650
  return "[" + label + "](" + buildDisplayExpiredAttachmentHref(fullPath, label) + ")";
@@ -661,6 +685,25 @@ Index the REMAINING windows - one record per row/item, looking at any page image
661
685
  out = out.replace(/[`'"*>]+$/, "");
662
686
  return out;
663
687
  }
688
+ var PREVIEWABLE_IMAGE_CONTENT_TYPES = {
689
+ png: "image/png",
690
+ jpg: "image/jpeg",
691
+ jpeg: "image/jpeg",
692
+ gif: "image/gif",
693
+ webp: "image/webp",
694
+ avif: "image/avif",
695
+ bmp: "image/bmp"
696
+ };
697
+ function previewableExtOf(nameOrPath) {
698
+ var v = String(nameOrPath || "");
699
+ var cut = v.search(/[?#]/);
700
+ if (cut !== -1) v = v.slice(0, cut);
701
+ v = v.replace(/[\\/]+$/, "");
702
+ var dot = v.lastIndexOf(".");
703
+ if (dot <= 0) return "";
704
+ var ext = v.slice(dot + 1).trim().toLowerCase();
705
+ return /^[a-z0-9]+$/.test(ext) ? ext : "";
706
+ }
664
707
  function classifyInlineLink(full, groups, ctx) {
665
708
  var g1 = groups[0], g2 = groups[1], g3 = groups[2], g4 = groups[3], g5 = groups[4], g6 = groups[5];
666
709
  var dbHostPrefix = (ctx.dbHostPrefix || "").toLowerCase();
@@ -674,17 +717,19 @@ Index the REMAINING windows - one record per row/item, looking at any page image
674
717
  if (!remotePath2) return null;
675
718
  var expiredHref = buildDisplayExpiredAttachmentHref(remotePath2, label);
676
719
  var cached = fresh(expiredHref);
677
- return {
678
- part: {
679
- type: "link",
680
- label: truncateLabelForDisplay(label),
681
- fullLabel: label,
682
- href: cached || expiredHref,
683
- expired: !cached,
684
- expiredHref,
685
- remotePath: remotePath2
686
- }
720
+ var part = {
721
+ type: "link",
722
+ label: truncateLabelForDisplay(label),
723
+ fullLabel: label,
724
+ href: cached || expiredHref,
725
+ expired: !cached,
726
+ expiredHref,
727
+ remotePath: remotePath2
687
728
  };
729
+ var ext = previewableExtOf(remotePath2);
730
+ var ct = PREVIEWABLE_IMAGE_CONTENT_TYPES[ext];
731
+ if (ct) part.image = { ext, contentType: ct };
732
+ return { part };
688
733
  };
689
734
  if (g1) {
690
735
  var rawPath = normalizeTrailingInlineToken(g1);
@@ -697,14 +742,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
697
742
  tail
698
743
  };
699
744
  }
700
- var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.serviceId) || normalizeAttachmentPathCandidate(rawPath) : normalizeAttachmentPathCandidate(rawPath));
745
+ var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.projectId) || normalizeAttachmentPathCandidate(rawPath) : rawPath.trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/"));
701
746
  var srcBuilt = asStoredFile(srcPath, srcPath);
702
747
  return srcBuilt ? { part: srcBuilt.part, tail } : null;
703
748
  }
704
749
  if (g4 && g5) {
705
750
  var dbTarget = /^db:(.+)$/i.exec(g5.trim());
706
751
  if (dbTarget) {
707
- var declared = asStoredFile(normalizeAttachmentPathCandidate(dbTarget[1]), g4);
752
+ var rawDbPath = dbTarget[1].trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/");
753
+ var declared = asStoredFile(rawDbPath, g4);
708
754
  if (!declared) return null;
709
755
  declared.part.label = truncateLabelForDisplay(g4);
710
756
  declared.part.fullLabel = g4;
@@ -749,8 +795,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
749
795
  return withTail(carriedBuilt);
750
796
  }
751
797
  }
752
- if (isServiceDbAttachmentHref(originalHref, ctx.serviceId)) {
753
- var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.serviceId);
798
+ if (isServiceDbAttachmentHref(originalHref, ctx.projectId)) {
799
+ var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.projectId);
754
800
  if (remotePath) {
755
801
  var dbBuilt = asStoredFile(remotePath, getExpiredAttachmentVisiblePath(remotePath, urlLabel));
756
802
  if (dbBuilt) return withTail(dbBuilt);
@@ -760,6 +806,19 @@ Index the REMAINING windows - one record per row/item, looking at any page image
760
806
  part: { type: "link", label: truncateLabelForDisplay(urlLabel), fullLabel: urlLabel, href: originalHref, expired: false }
761
807
  });
762
808
  }
809
+ function linkUnavailableKeyForPath(remotePath) {
810
+ return "path:" + (remotePath || "");
811
+ }
812
+ function linkUnavailableKeyForHref(href) {
813
+ return "href:" + (href || "");
814
+ }
815
+ function isLinkUnavailable(link, map) {
816
+ if (!link || !map) return false;
817
+ if (link.remotePath && map[linkUnavailableKeyForPath(link.remotePath)]) return true;
818
+ if (link.expiredHref && map[linkUnavailableKeyForHref(link.expiredHref)]) return true;
819
+ if (link.href && map[linkUnavailableKeyForHref(link.href)]) return true;
820
+ return false;
821
+ }
763
822
  function truncateLabelForDisplay(label) {
764
823
  if (!label) return label;
765
824
  if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
@@ -788,15 +847,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
788
847
  };
789
848
  var apiReportedContextWindows = {};
790
849
  var projectContextWindows = {};
791
- function setProjectContextWindow(serviceId, tokens) {
792
- var key = (serviceId || "").trim();
850
+ function setProjectContextWindow(projectId, tokens) {
851
+ var key = (projectId || "").trim();
793
852
  if (!key) return;
794
853
  var n = Number(tokens);
795
854
  if (Number.isFinite(n) && n > 0) projectContextWindows[key] = Math.floor(n);
796
855
  else delete projectContextWindows[key];
797
856
  }
798
- function getProjectContextWindow(serviceId) {
799
- var key = (serviceId || "").trim();
857
+ function getProjectContextWindow(projectId) {
858
+ var key = (projectId || "").trim();
800
859
  return key && projectContextWindows[key] ? projectContextWindows[key] : null;
801
860
  }
802
861
  var OUTPUT_TOKEN_RESERVE = 22e3;
@@ -813,8 +872,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
813
872
  function estimateMessageTokens(msg) {
814
873
  return estimateTextTokens(msg.content) + estimateTextTokens(msg.role) + 6;
815
874
  }
816
- function getContextWindow(platform, model, serviceId) {
817
- var override = serviceId ? getProjectContextWindow(serviceId) : null;
875
+ function getContextWindow(platform, model, projectId) {
876
+ var override = projectId ? getProjectContextWindow(projectId) : null;
818
877
  if (override) return override;
819
878
  var normalized = (model || "").trim().toLowerCase();
820
879
  if (normalized) {
@@ -833,12 +892,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
833
892
  return content.replace(/```([^\n`]+?\.[^\s.`]+)\n[\s\S]*?```/g, "[file previously attached: $1]");
834
893
  }
835
894
  function buildBoundedChatMessages(options) {
836
- var contextWindow = getContextWindow(options.platform, options.model, options.serviceId);
895
+ var contextWindow = getContextWindow(options.platform, options.model, options.projectId);
837
896
  var contextBasedBudget = Math.max(
838
897
  MIN_INPUT_TOKEN_BUDGET,
839
898
  contextWindow - OUTPUT_TOKEN_RESERVE - TOOL_AND_RESPONSE_BUFFER
840
899
  );
841
- var scaled = !!(options.serviceId && getProjectContextWindow(options.serviceId));
900
+ var scaled = !!(options.projectId && getProjectContextWindow(options.projectId));
842
901
  var claudeInputCap = scaled ? Math.max(CLAUDE_PER_REQUEST_INPUT_CAP, Math.round(contextBasedBudget * CLAUDE_INPUT_CAP_RATIO)) : CLAUDE_PER_REQUEST_INPUT_CAP;
843
902
  var availableInputBudget = options.platform === "claude" ? Math.min(contextBasedBudget, claudeInputCap) : contextBasedBudget;
844
903
  var systemCost = estimateTextTokens(options.systemPrompt) + 12;
@@ -850,7 +909,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
850
909
  var trimmed = windowed.map(function(m, i2) {
851
910
  if (i2 === latestIndex) return m;
852
911
  var stripped = stripFileBlocksFromHistory(m.content);
853
- var sanitized = sanitizeAttachmentLinksForHistory(stripped, options.serviceId, m.role !== "user");
912
+ var sanitized = sanitizeAttachmentLinksForHistory(stripped, options.projectId, m.role !== "user");
854
913
  return Object.assign({}, m, { content: sanitized });
855
914
  });
856
915
  var bounded = [], used = 0;
@@ -1027,6 +1086,141 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1027
1086
  };
1028
1087
  }
1029
1088
 
1089
+ // src/engine/link_markup.ts
1090
+ function escapeInlineHtml(v) {
1091
+ return String(v == null ? "" : v).replace(/[&<>"']/g, function(ch) {
1092
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
1093
+ });
1094
+ }
1095
+ var IMAGE_PREVIEWS_PER_MESSAGE = 8;
1096
+ var INLINE_LINK_GLYPH = "\u2197";
1097
+ var INLINE_LINK_UNAVAILABLE_GLYPH = "\u2715";
1098
+ var INLINE_LINK_UNAVAILABLE_SUFFIX = " (unavailable)";
1099
+ function renderInlineLinkHtml(link, opts) {
1100
+ var o = opts || {};
1101
+ var unavailable = !!o.unavailable;
1102
+ var refreshing = !unavailable && !!o.refreshing;
1103
+ var full = link.fullLabel || link.label;
1104
+ var preview = !!link.image && !!link.remotePath && o.allowImagePreview !== false && !unavailable;
1105
+ var cls = ["bq-link-button"];
1106
+ if (link.expired) cls.push("is-expired");
1107
+ if (refreshing) cls.push("is-refreshing");
1108
+ if (unavailable) cls.push("is-unavailable");
1109
+ if (preview) cls.push("is-image-preview");
1110
+ var labelText = (unavailable ? INLINE_LINK_UNAVAILABLE_GLYPH : INLINE_LINK_GLYPH) + " " + link.label + (unavailable ? INLINE_LINK_UNAVAILABLE_SUFFIX : refreshing ? " (fetching...)" : "");
1111
+ var attrs = ['class="' + cls.join(" ") + '"'];
1112
+ if (unavailable) attrs.push('aria-disabled="true"', 'data-bq-unavailable="1"');
1113
+ else attrs.push('href="' + escapeInlineHtml(link.href) + '"', 'target="_blank"', 'rel="noopener noreferrer"');
1114
+ attrs.push('title="' + escapeInlineHtml(unavailable ? full + INLINE_LINK_UNAVAILABLE_SUFFIX : full) + '"');
1115
+ if (!preview && !unavailable) attrs.push('download="' + escapeInlineHtml(full) + '"');
1116
+ attrs.push('data-bq-link="1"');
1117
+ if (link.expired && !unavailable) attrs.push('data-bq-expired="1"');
1118
+ if (link.expiredHref) attrs.push('data-bq-expired-href="' + escapeInlineHtml(link.expiredHref) + '"');
1119
+ if (link.remotePath) attrs.push('data-bq-remote-path="' + escapeInlineHtml(link.remotePath) + '"');
1120
+ if (link.fullLabel) attrs.push('data-bq-full-label="' + escapeInlineHtml(link.fullLabel) + '"');
1121
+ if (!preview) return "<a " + attrs.join(" ") + ">" + escapeInlineHtml(labelText) + "</a>";
1122
+ return "<a " + attrs.join(" ") + '><img class="bq-img-preview" alt="' + escapeInlineHtml(full) + '" data-bq-img-path="' + escapeInlineHtml(link.remotePath || "") + '" data-bq-img-type="' + escapeInlineHtml(link.image ? link.image.contentType : "") + '" loading="lazy" decoding="async"><span class="bq-loader" data-bq-img-loader="1"></span><span class="bq-img-preview-caption" translate="no">' + escapeInlineHtml(labelText) + "</span></a>";
1123
+ }
1124
+
1125
+ // src/engine/image_preview.ts
1126
+ var previewUrlCache = /* @__PURE__ */ Object.create(null);
1127
+ var previewInFlight = /* @__PURE__ */ Object.create(null);
1128
+ function cacheKey(scope, path) {
1129
+ return scope + "\0" + path;
1130
+ }
1131
+ function clearImagePreviewCache(scope) {
1132
+ var prefix = scope + "\0";
1133
+ for (var k in previewUrlCache) if (k.indexOf(prefix) === 0) delete previewUrlCache[k];
1134
+ for (var f in previewInFlight) if (f.indexOf(prefix) === 0) delete previewInFlight[f];
1135
+ for (var s in staleImagePreviews) if (s.indexOf(prefix) === 0) delete staleImagePreviews[s];
1136
+ }
1137
+ function peekImagePreviewUrl(ctx, remotePath) {
1138
+ var hit = previewUrlCache[cacheKey(ctx.scope, remotePath)];
1139
+ if (hit && Date.now() - hit.at < LINK_REFRESH_WINDOW_MS) return hit.url;
1140
+ return null;
1141
+ }
1142
+ function resolveImagePreviewUrl(ctx, remotePath, contentType, refresh) {
1143
+ var key = cacheKey(ctx.scope, remotePath);
1144
+ if (staleImagePreviews[key]) {
1145
+ delete staleImagePreviews[key];
1146
+ refresh = true;
1147
+ }
1148
+ if (refresh) {
1149
+ delete previewUrlCache[key];
1150
+ delete previewInFlight[key];
1151
+ } else {
1152
+ var warm = peekImagePreviewUrl(ctx, remotePath);
1153
+ if (warm) return Promise.resolve(warm);
1154
+ var flight = previewInFlight[key];
1155
+ if (flight) return flight;
1156
+ }
1157
+ var run = ctx.mint(remotePath, contentType, refresh).then(function(url) {
1158
+ if (previewInFlight[key] === run) {
1159
+ previewUrlCache[key] = { url, at: Date.now() };
1160
+ delete previewInFlight[key];
1161
+ }
1162
+ return url;
1163
+ }, function(e) {
1164
+ if (previewInFlight[key] === run) delete previewInFlight[key];
1165
+ throw e;
1166
+ });
1167
+ previewInFlight[key] = run;
1168
+ return run;
1169
+ }
1170
+ function markImagePreviewStale(scope, remotePath) {
1171
+ if (!remotePath) return;
1172
+ staleImagePreviews[cacheKey(scope, remotePath)] = true;
1173
+ delete previewUrlCache[cacheKey(scope, remotePath)];
1174
+ }
1175
+ var staleImagePreviews = /* @__PURE__ */ Object.create(null);
1176
+ function hydrateImagePreviews(imgs, ctx) {
1177
+ for (var i = 0; i < imgs.length; i++) hydrateOne(imgs[i], ctx);
1178
+ }
1179
+ function hydrateOne(img, ctx) {
1180
+ if (img.getAttribute("data-bq-img-state")) return;
1181
+ var path = img.getAttribute("data-bq-img-path");
1182
+ var type = img.getAttribute("data-bq-img-type") || "";
1183
+ if (!path) {
1184
+ img.setAttribute("data-bq-img-state", "error");
1185
+ return;
1186
+ }
1187
+ img.setAttribute("data-bq-img-state", "loading");
1188
+ img.addEventListener("load", function() {
1189
+ img.setAttribute("data-bq-img-state", "ready");
1190
+ if (ctx.onLoad) ctx.onLoad(path);
1191
+ });
1192
+ img.addEventListener("error", function() {
1193
+ onImageError(img, ctx, path, type);
1194
+ });
1195
+ var warm = peekImagePreviewUrl(ctx, path);
1196
+ if (warm) {
1197
+ img.setAttribute("src", warm);
1198
+ return;
1199
+ }
1200
+ resolveImagePreviewUrl(ctx, path, type).then(function(url) {
1201
+ if (img.getAttribute("data-bq-img-state") !== "loading") return;
1202
+ img.setAttribute("src", url);
1203
+ }, function(e) {
1204
+ img.setAttribute("data-bq-img-state", "error");
1205
+ if (ctx.onError) ctx.onError(path, e);
1206
+ });
1207
+ }
1208
+ function onImageError(img, ctx, path, type) {
1209
+ if (img.getAttribute("data-bq-img-retry") === "1") {
1210
+ img.setAttribute("data-bq-img-state", "error");
1211
+ if (ctx.onError) ctx.onError(path, new Error("image preview failed to load"));
1212
+ return;
1213
+ }
1214
+ img.setAttribute("data-bq-img-retry", "1");
1215
+ img.removeAttribute("src");
1216
+ resolveImagePreviewUrl(ctx, path, type, true).then(function(url) {
1217
+ img.setAttribute("src", url);
1218
+ }, function(e) {
1219
+ img.setAttribute("data-bq-img-state", "error");
1220
+ if (ctx.onError) ctx.onError(path, e);
1221
+ });
1222
+ }
1223
+
1030
1224
  // src/engine/time.ts
1031
1225
  function wallClockNow() {
1032
1226
  return Date.now();
@@ -1090,6 +1284,25 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1090
1284
  var DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
1091
1285
  var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
1092
1286
  var clientSecretRequest = (opts) => chatEngineConfig().clientSecretRequest(opts);
1287
+ var VARIANT_IMAGE_DETAIL = "original";
1288
+ var VARIANT_TEXT_VERBOSITY = "high";
1289
+ var OLDEST_NANO_REASONING_EFFORT = "high";
1290
+ var isOpenAINano = (model) => {
1291
+ const normalized = (model).trim().toLowerCase();
1292
+ if (!/(^|-)nano(-|$)/.test(normalized)) return false;
1293
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
1294
+ if (!match) return false;
1295
+ const major = Number(match[1]);
1296
+ const minor = match[2] === void 0 ? null : Number(match[2]);
1297
+ return major > 5 || major === 5 && minor !== null && minor >= 4;
1298
+ };
1299
+ var variantIndexingOptions = (model) => {
1300
+ if (!isOpenAINano(model) || !isOldestNano(model)) return {};
1301
+ return {
1302
+ ...{ text: { verbosity: VARIANT_TEXT_VERBOSITY } } ,
1303
+ ...{ reasoning: { effort: OLDEST_NANO_REASONING_EFFORT } }
1304
+ };
1305
+ };
1093
1306
  var getOpenAIImageDetail = (model) => {
1094
1307
  const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
1095
1308
  const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
@@ -1103,12 +1316,40 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1103
1316
  if (!supportsOriginal) {
1104
1317
  return DEFAULT_OPENAI_IMAGE_DETAIL;
1105
1318
  }
1106
- return isVariant ? "high" : "original";
1319
+ return isVariant ? VARIANT_IMAGE_DETAIL : "original";
1107
1320
  };
1108
1321
  var getRenderImageDetail = (model) => {
1109
1322
  const detail = getOpenAIImageDetail(model);
1110
1323
  return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? "high" : detail;
1111
1324
  };
1325
+ var OPENAI_VERSIONED_ID = /^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/;
1326
+ var isRecognisedOpenAIVersion = (model) => OPENAI_VERSIONED_ID.test((model).trim().toLowerCase());
1327
+ var isOldestNano = (model) => {
1328
+ const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
1329
+ if (!/(^|-)nano(-|$)/.test(normalized)) return false;
1330
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
1331
+ if (!match) return true;
1332
+ const major = Number(match[1]);
1333
+ const minor = match[2] === void 0 ? null : Number(match[2]);
1334
+ if (major < 5) return true;
1335
+ if (major > 5) return false;
1336
+ return minor === null || minor <= 4;
1337
+ };
1338
+ var SMALL_TIER_PAGES_PER_WINDOW = 2;
1339
+ var DOWNSAMPLED_TIER_TILE = 2;
1340
+ function getVisionProfile(model) {
1341
+ const detail = getRenderImageDetail(model);
1342
+ if (!isRecognisedOpenAIVersion(model)) {
1343
+ return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
1344
+ }
1345
+ if (detail !== "original") {
1346
+ return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: DOWNSAMPLED_TIER_TILE };
1347
+ }
1348
+ if (isOldestNano(model)) {
1349
+ return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: 1 };
1350
+ }
1351
+ return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
1352
+ }
1112
1353
  var IMAGE_URL_REGEX = /\bhttps?:\/\/[^\s<>"'()\[\]]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s<>"'()\[\]]*)?/gi;
1113
1354
  function transformContentWithImages(content) {
1114
1355
  if (typeof content !== "string" || !content) {
@@ -1189,6 +1430,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1189
1430
  });
1190
1431
  }
1191
1432
  var POLL_INTERVAL = 3e3;
1433
+ var MAX_CONCURRENT_BG_POLLS = 6;
1192
1434
  async function callClaudeWithMcp({
1193
1435
  prompt,
1194
1436
  messages,
@@ -1353,16 +1595,18 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1353
1595
  const renderFrom = Math.max(0, info.renderFrom || 0);
1354
1596
  const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : void 0;
1355
1597
  const renderDetail = platform === "openai" ? getRenderImageDetail(info.model || DEFAULT_OPENAI_MODEL) : void 0;
1598
+ const visionProfile = platform === "openai" ? getVisionProfile(info.model || DEFAULT_OPENAI_MODEL) : { pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
1356
1599
  const skapiRender = visionFile && renderPlaceholder ? {
1357
1600
  _skapi_render: [
1358
1601
  {
1359
1602
  path: attachment.storagePath,
1360
1603
  from: renderFrom,
1361
- count: RENDER_PAGES_PER_WINDOW,
1604
+ count: visionProfile.pagesPerWindow,
1362
1605
  placeholder: renderPlaceholder,
1363
1606
  name: attachment.name,
1364
1607
  mime: attachment.mime,
1365
1608
  detail: renderDetail,
1609
+ tile: visionProfile.tile,
1366
1610
  auto_continue: true,
1367
1611
  continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder)
1368
1612
  }
@@ -1379,6 +1623,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1379
1623
  name: attachment.name,
1380
1624
  mime: attachment.mime,
1381
1625
  kind: "window",
1626
+ // Same per-image `detail` the render path sends. Without it the worker falls
1627
+ // back to its model-blind default of 'high', so a spreadsheet's embedded
1628
+ // photos were tiled at lower resolution than the SAME model gets for a PDF
1629
+ // page or a chat attachment. That is why a model could describe an attached
1630
+ // photo but reported the pictures inside a sheet as only partly legible.
1631
+ detail: renderDetail,
1382
1632
  auto_continue: true,
1383
1633
  continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true)
1384
1634
  }
@@ -1388,13 +1638,27 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1388
1638
  const serverExtract = !visionFile && !windowedRead && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
1389
1639
  const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : void 0;
1390
1640
  const extractContent = serverExtract && placeholder ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }] : void 0;
1391
- const skapiExtract = extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
1641
+ const skapiExtract = extractContent && extractContent.length ? {
1642
+ _skapi_extract: extractContent.map((d) => ({
1643
+ ...d,
1644
+ // FIRST pass of an INDEXING run only: tells the worker to also pull the
1645
+ // file's embedded pictures into __MEDIA__ and register their records.
1646
+ // Chat-turn extraction (callClaudeWithMcp / callOpenAIWithPublicMcp)
1647
+ // never sets this, so merely ATTACHING a file to a chat message cannot
1648
+ // write media records; a CONTINUE pass skips it because the first pass
1649
+ // already saved (the save is whole-file, not windowed).
1650
+ save_media: !continuing
1651
+ }))
1652
+ } : {};
1392
1653
  const userMessage = visionFile && renderPlaceholder ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom) : windowedRead && windowPlaceholder ? buildIndexingWindowMessage(attachment, windowPlaceholder, false) : continuing ? buildIndexingContinueMessage(attachment) : buildIndexingUserMessage(
1393
1654
  attachment,
1394
1655
  parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : pagedRead ? { pagedRead: true } : void 0
1395
1656
  );
1396
1657
  const systemPrompt = buildIndexingSystemPrompt({
1397
- service,
1658
+ // The model copies this id verbatim into project_id tool calls, so it must be
1659
+ // the PUBLIC token whenever the host supplied one; the raw code is rejected
1660
+ // by the tools' schema pattern.
1661
+ projectId: info.publicProjectId || service,
1398
1662
  serviceName: info.serviceName,
1399
1663
  serviceDescription: info.serviceDescription
1400
1664
  });
@@ -1403,7 +1667,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1403
1667
  const imageDetail = getOpenAIImageDetail(resolvedModel2);
1404
1668
  return clientSecretRequest({
1405
1669
  clientSecretName: "openai",
1406
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
1670
+ queue: bgIndexingQueueName(info.userId, service),
1407
1671
  service,
1408
1672
  owner,
1409
1673
  ...pollOpt(),
@@ -1416,6 +1680,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1416
1680
  data: {
1417
1681
  model: resolvedModel2,
1418
1682
  max_output_tokens: MAX_TOKENS,
1683
+ // Nano-only transcription knobs. Indexing only; see variantIndexingOptions.
1684
+ ...variantIndexingOptions(resolvedModel2),
1419
1685
  ...skapiExtract,
1420
1686
  ...skapiRender,
1421
1687
  ...skapiWindow,
@@ -1447,7 +1713,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1447
1713
  const resolvedModel = info.model || DEFAULT_CLAUDE_MODEL;
1448
1714
  return clientSecretRequest({
1449
1715
  clientSecretName: "claude",
1450
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
1716
+ queue: bgIndexingQueueName(info.userId, service),
1451
1717
  service,
1452
1718
  owner,
1453
1719
  ...pollOpt(),
@@ -1536,6 +1802,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1536
1802
  return "";
1537
1803
  }
1538
1804
  var BG_INDEXING_QUEUE_SUFFIX = "-bg";
1805
+ function bgIndexingQueueName(userId, service) {
1806
+ return (userId || service || "") + BG_INDEXING_QUEUE_SUFFIX;
1807
+ }
1539
1808
  function isBgIndexingQueue(queueName) {
1540
1809
  if (typeof queueName !== "string" || !queueName) return false;
1541
1810
  const prefix = queueName.split("|")[0];
@@ -1544,7 +1813,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1544
1813
  return name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX;
1545
1814
  }
1546
1815
  var INDEXING_COMPLETE_MARKER = "INDEXING_COMPLETE";
1816
+ var EMPTY_INDEXING_REPLY = "Finished reading this file.";
1547
1817
  var MAX_INDEXING_RESUME_PASSES = 6;
1818
+ var CHAT_HISTORY_PAGE_LIMIT = 500;
1548
1819
  async function getChatHistory(params, fetchOptions) {
1549
1820
  const url = params.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1550
1821
  const p = Object.assign(
@@ -1558,7 +1829,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1558
1829
  );
1559
1830
  return chatEngineConfig().clientSecretRequestHistory(
1560
1831
  p,
1561
- Object.assign({ ascending: false }, fetchOptions)
1832
+ Object.assign({ ascending: false, limit: CHAT_HISTORY_PAGE_LIMIT }, fetchOptions)
1562
1833
  );
1563
1834
  }
1564
1835
 
@@ -1625,6 +1896,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1625
1896
  var userText = extractLastUserTextFromRequest(requestBody);
1626
1897
  var assistantText = isPending ? "" : (extractAssistantText(response) || "").trim() || "";
1627
1898
  var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
1899
+ var reportedComplete = !!(item && item._isBgTask) && !isErrorResponse && !!assistantText && assistantText.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
1900
+ if (reportedComplete) assistantText = assistantText.split(INDEXING_COMPLETE_MARKER).join("").trim();
1628
1901
  var serverItemId = item && typeof item.id === "string" && item.id ? item.id : void 0;
1629
1902
  var createdTs = Number(item && item.created);
1630
1903
  var updatedTs = Number(item && item.updated);
@@ -1649,7 +1922,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1649
1922
  displayContent = userText;
1650
1923
  }
1651
1924
  } else {
1652
- displayContent = sanitizeAttachmentLinksForHistory(userText, opts.serviceId);
1925
+ displayContent = sanitizeAttachmentLinksForHistory(userText, opts.projectId);
1653
1926
  }
1654
1927
  var userMsg = { role: "user", content: displayContent };
1655
1928
  if (isInProcess) userMsg.isPendingInProcess = true;
@@ -1676,11 +1949,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1676
1949
  if (serverItemId !== void 0) em._serverItemId = serverItemId;
1677
1950
  if (replyTs !== void 0) em._ts = replyTs;
1678
1951
  mapped.push(em);
1679
- } else if (assistantText) {
1680
- var okm = { role: "assistant", content: sanitizeAttachmentLinksForHistory(assistantText, opts.serviceId, true) };
1952
+ } else if (assistantText || reportedComplete) {
1953
+ var okm = { role: "assistant", content: sanitizeAttachmentLinksForHistory(assistantText, opts.projectId, true) || EMPTY_INDEXING_REPLY };
1681
1954
  if (item._isBgTask) okm.isBackgroundTask = true;
1682
1955
  if (serverItemId !== void 0) okm._serverItemId = serverItemId;
1683
1956
  if (replyTs !== void 0) okm._ts = replyTs;
1957
+ if (reportedComplete) okm._indexComplete = true;
1684
1958
  mapped.push(okm);
1685
1959
  }
1686
1960
  });
@@ -1739,6 +2013,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1739
2013
  function createHistoryFiller(base) {
1740
2014
  var pending = [];
1741
2015
  var running = false;
2016
+ var fetching = false;
2017
+ function announce(next) {
2018
+ if (fetching === next) return;
2019
+ fetching = next;
2020
+ if (!base.onRunningChange) return;
2021
+ try {
2022
+ base.onRunningChange(next);
2023
+ } catch (e) {
2024
+ }
2025
+ }
1742
2026
  async function allSatisfied() {
1743
2027
  var next = [];
1744
2028
  for (var i = 0; i < pending.length; i++) {
@@ -1748,23 +2032,33 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1748
2032
  return pending.length === 0;
1749
2033
  }
1750
2034
  return {
2035
+ // The published fact, so a view and `isRunning()` can never disagree about
2036
+ // what they are showing. A fill that never fetches is not something anyone
2037
+ // outside this module has any use for knowing about.
1751
2038
  isRunning: function() {
1752
- return running;
2039
+ return fetching;
1753
2040
  },
1754
2041
  fill: function(isSatisfied) {
1755
2042
  pending.push(isSatisfied);
1756
2043
  if (running) return Promise.resolve();
1757
2044
  running = true;
1758
2045
  var done = function() {
1759
- running = false;
1760
2046
  pending = [];
2047
+ running = false;
2048
+ announce(false);
1761
2049
  };
1762
2050
  return fillHistoryViewport({
1763
2051
  isSatisfied: allSatisfied,
1764
2052
  isEndOfList: base.isEndOfList,
1765
2053
  isLoading: base.isLoading,
1766
2054
  messageCount: base.messageCount,
1767
- fetchOlder: base.fetchOlder,
2055
+ // The span opens HERE, at the first real page request: past
2056
+ // isEndOfList, past isStale, past isSatisfied. Everything before this
2057
+ // point is a fill that concluded there was nothing to do.
2058
+ fetchOlder: function() {
2059
+ announce(true);
2060
+ return base.fetchOlder();
2061
+ },
1768
2062
  isStale: base.isStale,
1769
2063
  maxPages: base.maxPages
1770
2064
  }).then(done, done);
@@ -1774,7 +2068,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1774
2068
 
1775
2069
  // src/engine/session.ts
1776
2070
  var WORKER_PASS_ADOPT_LIMIT = 20;
2071
+ var LIVE_INDEX_SNAPSHOT_MAX_AGE_MS = 5e3;
2072
+ var INDEX_DISPATCH_CLAIM_MS = 2 * 60 * 1e3;
1777
2073
  var WORKER_PASS_ADOPT_ATTEMPTS = [0, 2e3, 6e3];
2074
+ var INDEXING_DRAIN_BUSY_POLL_MS = 8e3;
2075
+ var INDEXING_DRAIN_CONFIRM_POLL_MS = 3e3;
2076
+ var INDEXING_DRAIN_IDLE_LOOKS = 2;
2077
+ var INDEXING_DRAIN_MIN_MS = 8e3;
2078
+ var INDEXING_DRAIN_TIMEOUT_MS = 15 * 60 * 1e3;
2079
+ var INDEXING_DRAIN_LOOK_TIMEOUT_MS = 45e3;
2080
+ var INDEXING_DRAIN_NUDGE_MIN_GAP_MS = 1500;
1778
2081
  var _g = typeof globalThis !== "undefined" ? globalThis : {};
1779
2082
  function nowMs() {
1780
2083
  return _g.performance && typeof _g.performance.now === "function" ? _g.performance.now() : Date.now();
@@ -1836,7 +2139,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1836
2139
  historyEndOfList: false,
1837
2140
  historyStartKeyHistory: [],
1838
2141
  historyRequestToken: 0,
1839
- gateRefreshToken: 0
2142
+ gateRefreshToken: 0,
2143
+ liveIndexKeys: {},
2144
+ liveIndexChecked: false,
2145
+ stoppedIndexIds: {}
1840
2146
  };
1841
2147
  this.bgTaskQueue = [];
1842
2148
  this.cancelledServerIds = /* @__PURE__ */ new Set();
@@ -1847,6 +2153,298 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1847
2153
  this._pauseReasons = /* @__PURE__ */ new Set();
1848
2154
  this._resuming = false;
1849
2155
  this._lidSeq = 0;
2156
+ this._stageSeq = 0;
2157
+ this._uploadBatches = 0;
2158
+ this._indexDispatchesInFlight = 0;
2159
+ this._drainNudges = [];
2160
+ this._liveStages = {};
2161
+ this._liveIndexKey = "";
2162
+ this._liveIndexAt = 0;
2163
+ this._indexClaims = {};
2164
+ }
2165
+ /** What the display layer needs to decide whether a run is finished. `keys` holds
2166
+ * every file the server still has indexing work for; `checked` is false until the
2167
+ * first answer for this chat, and false means "we do not know yet". */
2168
+ getLiveIndexState() {
2169
+ return { keys: this.state.liveIndexKeys, checked: this.state.liveIndexChecked };
2170
+ }
2171
+ /** Passes that were on a row when the user stopped it, so the display layer can
2172
+ * still tell that this run was stopped once the stop has left no other trace.
2173
+ * See cancelIndexingGroup, which fills it, and buildChatDisplayList, which is
2174
+ * the only reader. */
2175
+ getStoppedIndexIds() {
2176
+ return this.state.stoppedIndexIds;
2177
+ }
2178
+ /**
2179
+ * Is this file ALREADY being indexed by this client?
2180
+ *
2181
+ * One live run per file, and the reason is what a second one looks like: the
2182
+ * conversation grows a SECOND collapsed row for the same file (a run is opened
2183
+ * by every FIRST pass, so two of them are two rows), the same document is read
2184
+ * twice at full provider cost, and the two chains fight over the same records —
2185
+ * the delete-then-repost that starts run 2 wipes what run 1 has saved so far.
2186
+ *
2187
+ * Asked of this client's own live work, so it cannot be wrong in the dangerous
2188
+ * direction: a queued/running pass keeps its bgTaskQueue entry until its bubble
2189
+ * settles, and a settled run answers false, which is what a genuine later
2190
+ * re-index needs.
2191
+ *
2192
+ * The retry that made this necessary: a chip whose INDEX request failed is
2193
+ * handed back to the composer to be retried on the next send, and an index
2194
+ * request can fail from the client's side (a lost ack, an expired token on the
2195
+ * response) while the server has already queued the pass. The retry then indexes
2196
+ * a file that was never not being indexed.
2197
+ */
2198
+ hasLiveIndexRun(storagePath) {
2199
+ if (!storagePath) return false;
2200
+ var claimed = this._indexClaims[this._indexClaimKey(storagePath)];
2201
+ if (claimed && nowMs() - claimed < INDEX_DISPATCH_CLAIM_MS) return true;
2202
+ var id = this.host.getIdentity();
2203
+ for (var i = 0; i < this.bgTaskQueue.length; i++) {
2204
+ var e = this.bgTaskQueue[i];
2205
+ if (e && e.storagePath === storagePath && e.projectId === id.projectId && e.platform === id.platform) return true;
2206
+ }
2207
+ return this.state.messages.some(function(m) {
2208
+ if (!m.isBackgroundTask || m.role !== "user" || m.isCancelled) return false;
2209
+ if (!(m.isPendingQueued || m.isPendingInProcess || m.isSendingToServer)) return false;
2210
+ return !!m._indexFile && m._indexFile.path === storagePath;
2211
+ });
2212
+ }
2213
+ /** Storage paths are project-relative, and one ChatSession serves every
2214
+ * project, so a claim has to be scoped the way a stop is (_indexKeyOf). */
2215
+ _indexClaimKey(storagePath) {
2216
+ return this.getHistoryCacheKey() + "|" + storagePath;
2217
+ }
2218
+ /**
2219
+ * Take this file's indexing slot, or report that someone already has it.
2220
+ *
2221
+ * The check-and-CLAIM is what makes it safe against a second caller arriving
2222
+ * mid-flight: the claim is written SYNCHRONOUSLY, before the first await, so a
2223
+ * concurrent caller sees it even though no request has completed and no queue
2224
+ * has admitted anything. Ask-then-dispatch could not do that — every source it
2225
+ * consults only learns about a dispatch after the ack.
2226
+ *
2227
+ * Returns true when the caller owns the slot and should dispatch. A caller that
2228
+ * then fails to dispatch MUST releaseIndexRun, or the file waits out the claim
2229
+ * (a few minutes) before it can be retried.
2230
+ */
2231
+ claimIndexRun(storagePath) {
2232
+ var self = this;
2233
+ if (!storagePath) return Promise.resolve(true);
2234
+ if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(false);
2235
+ this._indexClaims[this._indexClaimKey(storagePath)] = nowMs();
2236
+ return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS).then(function() {
2237
+ if (!self.state.liveIndexKeys[storagePath]) return true;
2238
+ self.releaseIndexRun(storagePath);
2239
+ return false;
2240
+ }).catch(function() {
2241
+ return true;
2242
+ });
2243
+ }
2244
+ /** Give the slot back — the dispatch failed, or was abandoned. */
2245
+ releaseIndexRun(storagePath) {
2246
+ if (storagePath) delete this._indexClaims[this._indexClaimKey(storagePath)];
2247
+ }
2248
+ /**
2249
+ * The same question, asked of the SERVER when this page cannot answer it.
2250
+ *
2251
+ * hasLiveIndexRun only knows what this page did. That is not enough for the
2252
+ * case duplicates actually come from: the first run was started before a
2253
+ * reload, or in another tab, or its bubble has since been paged out of the
2254
+ * loaded window — and then the retry finds nothing locally and starts a second
2255
+ * run of a file that is still being indexed. The queue is the one place that
2256
+ * knows, and it is already asked for exactly this list.
2257
+ *
2258
+ * Only a POSITIVE answer is used. Absence proves nothing here (the query is
2259
+ * capped, and `liveIndexChecked` records that), so an unanswerable question
2260
+ * falls back to dispatching — the cost of a wrong "no" is the duplicate this
2261
+ * exists to prevent, and the cost of a wrong "yes" is a file that never gets
2262
+ * indexed at all. Only one of those is recoverable by the user.
2263
+ */
2264
+ isIndexRunLive(storagePath) {
2265
+ var self = this;
2266
+ if (!storagePath) return Promise.resolve(false);
2267
+ if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(true);
2268
+ return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS).then(function() {
2269
+ return !!self.state.liveIndexKeys[storagePath];
2270
+ }).catch(function() {
2271
+ return false;
2272
+ });
2273
+ }
2274
+ /** Re-ask the queue which files are still being indexed, unless the answer we
2275
+ * have is younger than `maxAgeMs`. Shared by every caller that needs a current
2276
+ * one; the display layer's own refresh path is the adopt ladder. */
2277
+ _refreshLiveIndexKeys(maxAgeMs) {
2278
+ var self = this;
2279
+ var id = this.host.getIdentity();
2280
+ var platform = id.platform;
2281
+ if (!id.projectId || platform !== "claude" && platform !== "openai") return Promise.resolve();
2282
+ var askedKey = this.getHistoryCacheKey();
2283
+ if (this._liveIndexKey === askedKey && nowMs() - this._liveIndexAt < maxAgeMs) {
2284
+ return Promise.resolve();
2285
+ }
2286
+ var queue = bgIndexingQueueName(id.userId, id.projectId);
2287
+ var ask = function(status) {
2288
+ return Promise.resolve(getChatHistory(
2289
+ { service: id.projectId, owner: id.owner, platform, queue, status },
2290
+ { limit: WORKER_PASS_ADOPT_LIMIT }
2291
+ )).catch(function() {
2292
+ return null;
2293
+ });
2294
+ };
2295
+ return Promise.all([ask("pending"), ask("running")]).then(function(results) {
2296
+ if (results[0] === null || results[1] === null) return;
2297
+ if (self.getHistoryCacheKey() !== askedKey) return;
2298
+ self._liveIndexKey = askedKey;
2299
+ self._recordLiveIndexKeys(results);
2300
+ });
2301
+ }
2302
+ /**
2303
+ * Replace the live-index snapshot from a queue query's raw items.
2304
+ *
2305
+ * Whole-snapshot, never incremental: the query returns everything unresolved on
2306
+ * the queue, so a file MISSING from it is precisely the fact we are after. Merging
2307
+ * would make a finished file impossible to observe.
2308
+ */
2309
+ _recordLiveIndexKeys(lists) {
2310
+ var next = {};
2311
+ var truncated = false;
2312
+ var settledIds = {};
2313
+ this.state.messages.forEach(function(m) {
2314
+ if (!m._serverItemId) return;
2315
+ if (m.isPending || m.isPendingInProcess || m.isPendingQueued) return;
2316
+ settledIds[m._serverItemId] = true;
2317
+ });
2318
+ for (var li = 0; li < lists.length; li++) {
2319
+ var list = lists[li] && Array.isArray(lists[li].list) ? lists[li].list : [];
2320
+ if (list.length >= WORKER_PASS_ADOPT_LIMIT) truncated = true;
2321
+ for (var i = 0; i < list.length; i++) {
2322
+ var item = list[i];
2323
+ if (!item || item.status !== "pending" && item.status !== "running") continue;
2324
+ if (item.id && settledIds[item.id]) continue;
2325
+ var text = extractLastUserTextFromRequest(item.request_body);
2326
+ if (!isIndexingRequestText(text)) continue;
2327
+ var ref = parseIndexingRequestText(text);
2328
+ if (!ref) continue;
2329
+ if (ref.path) next[ref.path] = true;
2330
+ if (ref.name) next[ref.name] = true;
2331
+ }
2332
+ }
2333
+ var nowChecked = !truncated;
2334
+ var was = this.state.liveIndexKeys, changed = this.state.liveIndexChecked !== nowChecked;
2335
+ if (!changed) {
2336
+ for (var k in next) if (!was[k]) {
2337
+ changed = true;
2338
+ break;
2339
+ }
2340
+ if (!changed) {
2341
+ for (var k2 in was) if (!next[k2]) {
2342
+ changed = true;
2343
+ break;
2344
+ }
2345
+ }
2346
+ }
2347
+ this.state.liveIndexKeys = next;
2348
+ this.state.liveIndexChecked = nowChecked;
2349
+ this._liveIndexAt = nowMs();
2350
+ this._liveIndexKey = this.getHistoryCacheKey();
2351
+ if (changed) this.host.notify();
2352
+ }
2353
+ /** Forget the snapshot: it describes ONE chat's queue, and the answer for the
2354
+ * project the user just switched to is unknown until it is asked for again. */
2355
+ _resetLiveIndexKeys() {
2356
+ this.state.liveIndexKeys = {};
2357
+ this.state.liveIndexChecked = false;
2358
+ this._liveIndexAt = 0;
2359
+ }
2360
+ /**
2361
+ * Ask the queue what is still indexing, once, for the chat that is on screen.
2362
+ *
2363
+ * Seeds the snapshot on a history load. Without it a reloaded chat has no way to
2364
+ * learn that a run it can see is over: the adopt ladder that normally answers this
2365
+ * only fires when a pass SETTLES, and after a reload there is no pass left to
2366
+ * settle — so every finished worker-driven row would spin forever.
2367
+ *
2368
+ * Best-effort: a failure leaves `checked` false, which reads as "still working"
2369
+ * rather than as a false all-clear.
2370
+ *
2371
+ * Delegates to the adopt ladder rather than asking once. A single empty look is
2372
+ * exactly what that ladder exists to distrust — the worker writes pass N+1 a few
2373
+ * milliseconds AFTER flipping pass N to resolved, so a query landing in that gap
2374
+ * sees an empty queue for a chain that is very much alive. One look would turn
2375
+ * that into a confident "Indexed" with a green check, on the one scenario this
2376
+ * whole feature is for, and nothing would ever re-ask: the ladder is normally
2377
+ * triggered by a pass SETTLING, and after a reload there is no pass left to
2378
+ * settle. The ladder re-asks at 0/2s/6s, records each answer, and as a bonus
2379
+ * adopts and polls any live pass it finds, which makes the row genuinely active
2380
+ * instead of merely unconfirmed.
2381
+ */
2382
+ refreshLiveIndexState() {
2383
+ this._adoptWorkerIndexingPasses(0);
2384
+ }
2385
+ /** Forget what we know about which files are indexing — but ONLY when the
2386
+ * snapshot was taken for a different chat than the one on screen now. For a
2387
+ * consumer whose history loading is its own fork and so never reaches
2388
+ * loadHistory's reset — a snapshot describes ONE chat's queue, and carrying it
2389
+ * into another project would let a row there claim to be finished on someone
2390
+ * else's evidence.
2391
+ *
2392
+ * Conditional for the same reason loadHistory's own reset is (the
2393
+ * `loadKey !== _liveIndexKey` gate): the view calls this on every mount, and
2394
+ * an unconditional wipe turned every re-entry to the chat into a grey
2395
+ * "Checking status:" sweep across rows whose state was already known. A
2396
+ * RE-entry keeps showing the last answer (green/yellow) while the first-page
2397
+ * refresh re-asks quietly; only a genuine project/platform switch starts from
2398
+ * "not known yet". Claiming `_liveIndexKey` here (before any answer) is the
2399
+ * same fudge loadHistory makes: it marks WHOSE chat the empty snapshot is
2400
+ * for, so repeated calls do not re-wipe, and _recordLiveIndexKeys re-claims
2401
+ * it when the real answer lands. */
2402
+ resetLiveIndexState() {
2403
+ var key = this.getHistoryCacheKey();
2404
+ if (key === this._liveIndexKey) return;
2405
+ this._liveIndexKey = key;
2406
+ this._resetLiveIndexKeys();
2407
+ }
2408
+ /** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
2409
+ * live work from the moment it is sent, not from the moment it is acked. */
2410
+ trackIndexDispatch(p) {
2411
+ var self = this;
2412
+ this._indexDispatchesInFlight += 1;
2413
+ var release = function() {
2414
+ self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1);
2415
+ };
2416
+ return p.then(function(v) {
2417
+ release();
2418
+ return v;
2419
+ }, function(e) {
2420
+ release();
2421
+ throw e;
2422
+ });
2423
+ }
2424
+ /**
2425
+ * Something just happened that plausibly ENDED indexing work, so let any waiting
2426
+ * turn look now instead of sitting out the rest of its busy interval.
2427
+ *
2428
+ * A nudge changes only WHEN a look happens, never what it concludes: the two
2429
+ * agreeing idle looks, the confirm gap between them, "a failed look counts as
2430
+ * busy" and the minimum wait are all untouched. That is why it is safe to fire
2431
+ * from places that are merely good guesses.
2432
+ *
2433
+ * Fired from end-of-chain points ONLY: the adopt ladder giving up, a resume
2434
+ * declining to continue, a pass failing. Not from every settling pass (one nudge
2435
+ * per pass per file for the whole run), and not from an indexing request being
2436
+ * accepted — see the note in trackIndexDispatch for why that one is actively
2437
+ * harmful rather than merely wasteful.
2438
+ */
2439
+ _nudgeIndexingDrain() {
2440
+ if (!this._drainNudges.length) return;
2441
+ var list = this._drainNudges.slice();
2442
+ for (var i = 0; i < list.length; i++) {
2443
+ try {
2444
+ list[i]();
2445
+ } catch (e) {
2446
+ }
2447
+ }
1850
2448
  }
1851
2449
  /**
1852
2450
  * Register a live poll so (a) a remount dedupes against it instead of stacking a
@@ -1863,6 +2461,18 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1863
2461
  this.historyItemPolls.set(id, { kind, stop });
1864
2462
  return p;
1865
2463
  }
2464
+ /** Background polls currently attached, for the MAX_CONCURRENT_BG_POLLS budget.
2465
+ * Counts the registry rather than a separate tally so it cannot drift: every
2466
+ * attach goes through _trackPoll and every detach deletes the entry. Note an
2467
+ * entry left behind by pausePolling on an older skapi-js (no stop handle)
2468
+ * still counts, which is correct — that poll really is still running. */
2469
+ _countBgPolls() {
2470
+ var n = 0;
2471
+ this.historyItemPolls.forEach(function(handle) {
2472
+ if (handle && handle.kind === "bg") n++;
2473
+ });
2474
+ return n;
2475
+ }
1866
2476
  /**
1867
2477
  * Stop and forget one item's poll. Used after a cancel: the row is either gone
1868
2478
  * (cancelled while queued) or flagged cancelled (cancelled while running), so
@@ -1944,8 +2554,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1944
2554
  }
1945
2555
  getHistoryCacheKey() {
1946
2556
  var id = this.host.getIdentity();
1947
- if (!id.serviceId || id.platform === "none") return "";
1948
- return id.serviceId + "#" + id.platform;
2557
+ if (!id.projectId || id.platform === "none") return "";
2558
+ return id.projectId + "#" + id.platform;
1949
2559
  }
1950
2560
  updateHistoryCache() {
1951
2561
  var key = this.getHistoryCacheKey();
@@ -1998,6 +2608,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1998
2608
  for (var j = 0; j < msgs.length; j++) {
1999
2609
  var u = msgs[j];
2000
2610
  if (!u || u.role !== "user" || u.isBackgroundTask) continue;
2611
+ if (u._stageId) continue;
2001
2612
  if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
2002
2613
  if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
2003
2614
  var settled = { role: "user", content: u.content };
@@ -2013,26 +2624,26 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2013
2624
  };
2014
2625
  }
2015
2626
  /**
2016
- * serviceId/owner are passed explicitly by every caller: a request can be
2627
+ * projectId/owner are passed explicitly by every caller: a request can be
2017
2628
  * dispatched after the user moved to another project, and re-reading the live
2018
2629
  * identity here would silently send the turn to THAT project instead of the
2019
2630
  * one it was composed for. Falls back to the live read only when a caller
2020
2631
  * omits them.
2021
2632
  */
2022
- _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls, serviceId, owner) {
2023
- if (serviceId === void 0 || owner === void 0) {
2633
+ _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls, projectId, owner) {
2634
+ if (projectId === void 0 || owner === void 0) {
2024
2635
  var id = this.host.getIdentity();
2025
- if (serviceId === void 0) serviceId = id.serviceId;
2636
+ if (projectId === void 0) projectId = id.projectId;
2026
2637
  if (owner === void 0) owner = id.owner;
2027
2638
  }
2028
- return platform === "openai" ? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
2639
+ return platform === "openai" ? callOpenAIWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls);
2029
2640
  }
2030
2641
  dispatchAgentRequest(params) {
2031
2642
  var self = this;
2032
2643
  var dispatchItemId;
2033
2644
  var sendAndPoll = function() {
2034
2645
  return Promise.resolve(
2035
- self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
2646
+ self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.projectId, params.owner)
2036
2647
  ).then(function(initial) {
2037
2648
  if (initial && initial.poll && (initial.status === "pending" || initial.status === "running")) {
2038
2649
  if (initial.id) {
@@ -2091,16 +2702,304 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2091
2702
  this.pendingAgentRequests[params.key] = run;
2092
2703
  return run;
2093
2704
  }
2705
+ /**
2706
+ * Put a turn on screen the INSTANT the user hits Send, before its attachments
2707
+ * have finished uploading. Uploads run in the background now (the composer is
2708
+ * cleared and stays usable), so without a staged bubble the message would
2709
+ * appear only once its files were up — below anything the user sent in the
2710
+ * meantime, in an order that never matches what they typed.
2711
+ *
2712
+ * Staged bubbles carry _useBgQueue because that is where a turn with
2713
+ * attachments ultimately dispatches (behind its own indexing tasks). That flag
2714
+ * is also what keeps promoteNextQueuedToRunning / resolveQueuedUserBubble off
2715
+ * them: those advance the SERVER queue, and a staged turn has no server
2716
+ * request behind it yet.
2717
+ *
2718
+ * Returns the id to hand back as PinnedDispatchContext.stageId at dispatch.
2719
+ */
2720
+ stageOutgoingMessage(displayText) {
2721
+ this._stageSeq += 1;
2722
+ var stageId = "stg_" + this._stageSeq;
2723
+ var key = this.getHistoryCacheKey();
2724
+ var staged = {
2725
+ role: "user",
2726
+ content: displayText,
2727
+ isPendingQueued: true,
2728
+ isUploadingAttachments: true,
2729
+ isSendingToServer: true,
2730
+ _dimSending: true,
2731
+ // A staged bubble has no server id for minutes, and its indexing rows are
2732
+ // now inserted ABOVE it — so its array index moves. Both views fall back to
2733
+ // the index when a bubble has no id, which would re-key (and in Vue, remount)
2734
+ // this bubble on every file, restarting its transition and losing it as a
2735
+ // scroll anchor. A local id it keeps for its whole life fixes both.
2736
+ _localId: this._newLocalId(),
2737
+ _useBgQueue: true,
2738
+ _stageId: stageId,
2739
+ _ts: wallClockNow()
2740
+ };
2741
+ if (key) staged._ownerKey = key;
2742
+ this._liveStages[stageId] = true;
2743
+ this.state.messages.push(staged);
2744
+ this.host.notify();
2745
+ this.host.scrollToBottom(true);
2746
+ return stageId;
2747
+ }
2748
+ /** Is anything in this page still uploading/dispatching for this stage? */
2749
+ isLiveStage(stageId) {
2750
+ return !!stageId && !!this._liveStages[stageId];
2751
+ }
2752
+ /**
2753
+ * Settle any staged bubble in `list` whose chain no longer exists, and return the
2754
+ * list (a new array only if something changed).
2755
+ *
2756
+ * The caller is a cache restore. A staged bubble is the one kind of message whose
2757
+ * resolution lives entirely in page memory — no server request stands behind it
2758
+ * yet — so a copy that outlives its upload would render "(Uploading files...)"
2759
+ * forever with nothing left to finish it. Today nothing can: this cache dies with
2760
+ * the page, so every restored stage is still live and this is a no-op. It exists
2761
+ * so that stops being a silent assumption.
2762
+ */
2763
+ settleDeadStagedMessages(list) {
2764
+ if (!Array.isArray(list)) return list;
2765
+ var self = this;
2766
+ var dead = false;
2767
+ for (var i = 0; i < list.length; i++) {
2768
+ var m = list[i];
2769
+ if (m && m._stageId && !self._liveStages[m._stageId]) {
2770
+ dead = true;
2771
+ break;
2772
+ }
2773
+ }
2774
+ if (!dead) return list;
2775
+ return list.map(function(m2) {
2776
+ if (!m2 || !m2._stageId || self._liveStages[m2._stageId]) return m2;
2777
+ var settled = { role: "user", content: m2.content };
2778
+ if (m2._ownerKey !== void 0) settled._ownerKey = m2._ownerKey;
2779
+ if (m2._ts !== void 0) settled._ts = m2._ts;
2780
+ if (m2._localId !== void 0) settled._localId = m2._localId;
2781
+ return settled;
2782
+ });
2783
+ }
2784
+ _stageIndex(list, stageId) {
2785
+ if (!stageId) return -1;
2786
+ for (var i = 0; i < list.length; i++) {
2787
+ if (list[i] && list[i]._stageId === stageId) return i;
2788
+ }
2789
+ return -1;
2790
+ }
2791
+ /**
2792
+ * Staged turn, phase 2: its files are up and it is now waiting for the whole
2793
+ * indexing chain behind them. Swaps "(Uploading files...)" for
2794
+ * "(Indexing files...)"; the bubble stays dimmed, because from the user's side
2795
+ * nothing has been handed over yet.
2796
+ *
2797
+ * It deliberately does NOT say "(In queue)" here. The turn is not queued behind
2798
+ * anything the server knows about yet — it is waiting on work that can run for
2799
+ * minutes — and claiming otherwise is what made the wait look like a stall.
2800
+ */
2801
+ markStagedMessageIndexing(stageId) {
2802
+ var idx = this._stageIndex(this.state.messages, stageId);
2803
+ if (idx === -1) return;
2804
+ var ex = this.state.messages[idx];
2805
+ if (!ex.isUploadingAttachments) return;
2806
+ this.state.messages[idx] = Object.assign({}, ex, {
2807
+ isUploadingAttachments: false,
2808
+ isAwaitingIndexing: true
2809
+ });
2810
+ this.host.notify();
2811
+ }
2812
+ /**
2813
+ * Staged turn, phase 3: the last of its files has finished indexing, so the turn
2814
+ * is genuinely just queued now. Full opacity + "(In queue)".
2815
+ *
2816
+ * Clears the PRESENTATIONAL _dimSending only; isSendingToServer stays set until
2817
+ * the server actually acks (it is the token that ack matches on). Called by the
2818
+ * clients the instant awaitIndexingDrained resolves, i.e. immediately before the
2819
+ * dispatch that replaces this bubble — dispatchComposedMessage carries the
2820
+ * cleared flag onto the replacement so the turn does not blink back to dimmed.
2821
+ */
2822
+ markStagedMessageReady(stageId) {
2823
+ var idx = this._stageIndex(this.state.messages, stageId);
2824
+ if (idx === -1) return;
2825
+ var ex = this.state.messages[idx];
2826
+ if (!ex.isAwaitingIndexing && !ex._dimSending && !ex.isUploadingAttachments) return;
2827
+ this.state.messages[idx] = Object.assign({}, ex, {
2828
+ isUploadingAttachments: false,
2829
+ isAwaitingIndexing: false,
2830
+ _dimSending: false
2831
+ });
2832
+ this.host.notify();
2833
+ }
2834
+ /**
2835
+ * Resolves once this project's background-indexing queue has nothing left to
2836
+ * run, so a chat enqueued right after it is genuinely last.
2837
+ *
2838
+ * Sending the chat as soon as the uploads finish is not enough, which is the
2839
+ * whole reason this exists: indexing a file is a CHAIN, and each pass is only
2840
+ * enqueued once the previous one lands (the client mints CONTINUE passes for
2841
+ * text/grid files, the worker mints them for PDFs and windowed reads). Every
2842
+ * one of those passes therefore queues up BEHIND a chat sent at upload time,
2843
+ * and the model answers from a file it has only partly read.
2844
+ *
2845
+ * The queue is read from the server's status index rather than from
2846
+ * bgTaskQueue: that mirror holds only what this client dispatched or adopted,
2847
+ * and it stops being maintained once the view unmounts. An empty answer has to
2848
+ * repeat before it is believed — see INDEXING_DRAIN_IDLE_LOOKS — and a look
2849
+ * that fails counts as busy, so a dropped request delays the turn instead of
2850
+ * releasing it early.
2851
+ *
2852
+ * Reads the identity PINNED at Send time, never a live one: the user may be in
2853
+ * another project by now, and this must keep asking about the one they sent
2854
+ * from.
2855
+ */
2856
+ awaitIndexingDrained(identity) {
2857
+ var self = this;
2858
+ var svcId = identity && identity.projectId;
2859
+ var platform = identity && identity.platform;
2860
+ if (!svcId || platform !== "claude" && platform !== "openai") return Promise.resolve("skipped");
2861
+ var owner = identity.owner;
2862
+ var queue = bgIndexingQueueName(identity.userId, svcId);
2863
+ var startedAt = nowMs();
2864
+ var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
2865
+ var idleLooks = 0;
2866
+ var ask = function(status) {
2867
+ var answered = false;
2868
+ return new Promise(function(res) {
2869
+ var bail = null;
2870
+ var settle = function(v) {
2871
+ if (answered) return;
2872
+ answered = true;
2873
+ if (bail) {
2874
+ clearTimeout(bail);
2875
+ bail = null;
2876
+ }
2877
+ res(v);
2878
+ };
2879
+ bail = setTimeout(function() {
2880
+ settle(null);
2881
+ }, INDEXING_DRAIN_LOOK_TIMEOUT_MS);
2882
+ Promise.resolve(getChatHistory(
2883
+ { service: svcId, owner, platform, queue, status },
2884
+ { limit: WORKER_PASS_ADOPT_LIMIT }
2885
+ )).then(function(r) {
2886
+ settle(r);
2887
+ }, function() {
2888
+ settle(null);
2889
+ });
2890
+ });
2891
+ };
2892
+ var hasLiveIndexing = function(res) {
2893
+ var list = res && Array.isArray(res.list) ? res.list : [];
2894
+ for (var i = 0; i < list.length; i++) {
2895
+ var item = list[i];
2896
+ if (!item || item.status !== "pending" && item.status !== "running") continue;
2897
+ if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) return true;
2898
+ }
2899
+ return false;
2900
+ };
2901
+ return new Promise(function(resolve) {
2902
+ var timer = null;
2903
+ var lastLookAt = -Infinity;
2904
+ var nudgedThisInterval = false;
2905
+ var inFlight = false;
2906
+ var finish = function(v) {
2907
+ if (timer) {
2908
+ clearTimeout(timer);
2909
+ timer = null;
2910
+ }
2911
+ var ni = self._drainNudges.indexOf(nudge);
2912
+ if (ni !== -1) self._drainNudges.splice(ni, 1);
2913
+ resolve(v);
2914
+ };
2915
+ var again = function(ms) {
2916
+ if (timer) {
2917
+ clearTimeout(timer);
2918
+ timer = null;
2919
+ }
2920
+ var wait = ms == null ? idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS : ms;
2921
+ timer = setTimeout(look, wait);
2922
+ };
2923
+ var nudge = function() {
2924
+ if (idleLooks > 0) return;
2925
+ if (inFlight) return;
2926
+ if (nudgedThisInterval) return;
2927
+ if (self._indexDispatchesInFlight > 0) return;
2928
+ nudgedThisInterval = true;
2929
+ again(Math.max(0, INDEXING_DRAIN_NUDGE_MIN_GAP_MS - (nowMs() - lastLookAt)));
2930
+ };
2931
+ var look = function() {
2932
+ timer = null;
2933
+ if (inFlight) return;
2934
+ lastLookAt = nowMs();
2935
+ nudgedThisInterval = false;
2936
+ if (nowMs() >= deadline) {
2937
+ finish("timedout");
2938
+ return;
2939
+ }
2940
+ if (self._indexDispatchesInFlight > 0) {
2941
+ idleLooks = 0;
2942
+ again();
2943
+ return;
2944
+ }
2945
+ inFlight = true;
2946
+ Promise.all([ask("running"), ask("pending")]).then(function(res) {
2947
+ inFlight = false;
2948
+ var unknown = res[0] === null || res[1] === null;
2949
+ if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
2950
+ else idleLooks += 1;
2951
+ if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
2952
+ finish("drained");
2953
+ return;
2954
+ }
2955
+ again();
2956
+ }, function() {
2957
+ inFlight = false;
2958
+ idleLooks = 0;
2959
+ again();
2960
+ });
2961
+ };
2962
+ self._drainNudges.push(nudge);
2963
+ look();
2964
+ });
2965
+ }
2966
+ /**
2967
+ * Abandon a staged turn — its uploads failed outright, so nothing will be
2968
+ * dispatched. The bubble stays (the user's text is not silently thrown away)
2969
+ * but settles into a plain, non-pending message; the caller reports the
2970
+ * failure separately.
2971
+ */
2972
+ settleStagedMessage(stageId) {
2973
+ delete this._liveStages[stageId];
2974
+ var idx = this._stageIndex(this.state.messages, stageId);
2975
+ if (idx === -1) return;
2976
+ var ex = this.state.messages[idx];
2977
+ var settled = { role: "user", content: ex.content };
2978
+ if (ex._ownerKey !== void 0) settled._ownerKey = ex._ownerKey;
2979
+ if (ex._ts !== void 0) settled._ts = ex._ts;
2980
+ if (ex._localId !== void 0) settled._localId = ex._localId;
2981
+ this.state.messages[idx] = settled;
2982
+ this.host.notify();
2983
+ this.updateHistoryCache();
2984
+ }
2094
2985
  // composed = clean display text; composedForLlm carries office-extraction
2095
2986
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
2096
2987
  // onto the "-bg" queue so it runs after indexing.
2097
2988
  dispatchComposedMessage(composed, useBgQueue, composedForLlm, extractContent, fileUrls, pinned) {
2098
2989
  var self = this;
2099
- if (!composed) return;
2990
+ var stageId = pinned ? pinned.stageId : void 0;
2991
+ if (!composed) {
2992
+ if (stageId) this.settleStagedMessage(stageId);
2993
+ return;
2994
+ }
2100
2995
  var id = pinned ? pinned.identity : this.host.getIdentity();
2101
- if (id.platform === "none") return;
2996
+ if (id.platform === "none") {
2997
+ if (stageId) this.settleStagedMessage(stageId);
2998
+ return;
2999
+ }
3000
+ if (stageId) delete this._liveStages[stageId];
2102
3001
  var llmComposed = composedForLlm || composed;
2103
- var key = !id.serviceId ? "" : id.serviceId + "#" + id.platform;
3002
+ var key = !id.projectId ? "" : id.projectId + "#" + id.platform;
2104
3003
  var offChat = !!key && key !== this.getHistoryCacheKey();
2105
3004
  var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function(m) {
2106
3005
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
@@ -2108,8 +3007,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2108
3007
  var aiPlatform = id.platform;
2109
3008
  var aiModel = id.model || void 0;
2110
3009
  var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
2111
- var userId = id.userId || id.serviceId;
2112
- var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
3010
+ var userId = id.userId || id.projectId;
3011
+ var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
2113
3012
  if (offChat) {
2114
3013
  var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function(m) {
2115
3014
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
@@ -2118,13 +3017,28 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2118
3017
  platform: aiPlatform,
2119
3018
  model: aiModel,
2120
3019
  systemPrompt,
2121
- serviceId: id.serviceId,
3020
+ projectId: id.projectId,
2122
3021
  history: offHistory.concat([{ role: "user", content: llmComposed }])
2123
3022
  });
2124
3023
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
3024
+ var offUser = { role: "user", content: composed, _ownerKey: key, _ts: wallClockNow() };
3025
+ var offStage = this._stageIndex(this.state.messages, stageId);
3026
+ if (offStage !== -1) {
3027
+ if (this.state.messages[offStage]._ts !== void 0) offUser._ts = this.state.messages[offStage]._ts;
3028
+ this.state.messages.splice(offStage, 1);
3029
+ this.host.notify();
3030
+ }
3031
+ var offCached = offExisting.messages;
3032
+ if (stageId) {
3033
+ offCached = offCached.filter(function(m) {
3034
+ if (m._stageId !== stageId) return true;
3035
+ if (offStage === -1 && m._ts !== void 0) offUser._ts = m._ts;
3036
+ return false;
3037
+ });
3038
+ }
2125
3039
  this.aiChatHistoryCache[key] = {
2126
- messages: offExisting.messages.concat([
2127
- { role: "user", content: composed, _ownerKey: key, _ts: wallClockNow() },
3040
+ messages: offCached.concat([
3041
+ offUser,
2128
3042
  { role: "assistant", content: "", isPending: true, isPendingInProcess: true, _ownerKey: key }
2129
3043
  ]),
2130
3044
  endOfList: offExisting.endOfList,
@@ -2132,7 +3046,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2132
3046
  };
2133
3047
  this.dispatchAgentRequest({
2134
3048
  key,
2135
- serviceId: id.serviceId,
3049
+ projectId: id.projectId,
2136
3050
  owner: id.owner,
2137
3051
  aiPlatform,
2138
3052
  aiModel,
@@ -2153,24 +3067,33 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2153
3067
  platform: aiPlatform,
2154
3068
  model: aiModel,
2155
3069
  systemPrompt,
2156
- serviceId: id.serviceId,
3070
+ projectId: id.projectId,
2157
3071
  history: resolvedHistory.concat([{ role: "user", content: llmComposed }])
2158
3072
  });
2159
- var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
3073
+ var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _dimSending: true, _localId: this._newLocalId(), _ts: wallClockNow() };
2160
3074
  if (key) queuedBubble._ownerKey = key;
2161
3075
  if (useBgQueue) queuedBubble._useBgQueue = true;
2162
- this.state.messages.push(queuedBubble);
3076
+ var qStage = this._stageIndex(this.state.messages, stageId);
3077
+ if (qStage !== -1) {
3078
+ var qEx = this.state.messages[qStage];
3079
+ if (qEx._ts !== void 0) queuedBubble._ts = qEx._ts;
3080
+ if (qEx._dimSending === false) queuedBubble._dimSending = false;
3081
+ if (qEx._localId) queuedBubble._localId = qEx._localId;
3082
+ this.state.messages.splice(qStage, 1, queuedBubble);
3083
+ } else {
3084
+ this.state.messages.push(queuedBubble);
3085
+ }
2163
3086
  this.host.notify();
2164
3087
  this.updateHistoryCache();
2165
3088
  this.host.scrollToBottom(true);
2166
3089
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
2167
- Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner)).then(function(result) {
3090
+ Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.projectId, id.owner)).then(function(result) {
2168
3091
  var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function(m) {
2169
- return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
3092
+ return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && !m._stageId && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
2170
3093
  });
2171
3094
  var serverId = result && typeof result.id === "string" ? result.id : void 0;
2172
3095
  if (sendingIdx >= 0) {
2173
- var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false });
3096
+ var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false, _dimSending: false });
2174
3097
  if (serverId) upd._serverItemId = serverId;
2175
3098
  self.state.messages[sendingIdx] = upd;
2176
3099
  self.host.notify();
@@ -2191,33 +3114,37 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2191
3114
  });
2192
3115
  return;
2193
3116
  }
2194
- this.state.messages.push({ role: "user", content: composed, _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} });
2195
- this.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} });
3117
+ var immediateUser = { role: "user", content: composed, _localId: this._newLocalId(), _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} };
3118
+ var immediatePlaceholder = { role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} };
3119
+ var iStage = this._stageIndex(this.state.messages, stageId);
3120
+ if (iStage !== -1) {
3121
+ var iEx = this.state.messages[iStage];
3122
+ if (iEx._ts !== void 0) immediateUser._ts = iEx._ts;
3123
+ if (iEx._localId) immediateUser._localId = iEx._localId;
3124
+ this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
3125
+ } else {
3126
+ this.state.messages.push(immediateUser);
3127
+ this.state.messages.push(immediatePlaceholder);
3128
+ }
2196
3129
  this.host.notify();
2197
3130
  this.updateHistoryCache();
2198
3131
  this.state.sending = true;
2199
3132
  this.host.scrollToBottom(true);
2200
3133
  var historyForLlm = this.state.messages.filter(function(m) {
3134
+ if (m === immediateUser) return false;
2201
3135
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
2202
3136
  });
2203
- if (llmComposed !== composed) {
2204
- for (var li = historyForLlm.length - 1; li >= 0; li--) {
2205
- if (historyForLlm[li].role === "user" && historyForLlm[li].content === composed) {
2206
- historyForLlm[li] = Object.assign({}, historyForLlm[li], { content: llmComposed });
2207
- break;
2208
- }
2209
- }
2210
- }
3137
+ historyForLlm.push({ role: "user", content: llmComposed });
2211
3138
  var bounded = buildBoundedChatMessages({
2212
3139
  platform: aiPlatform,
2213
3140
  model: aiModel,
2214
3141
  systemPrompt,
2215
- serviceId: id.serviceId,
3142
+ projectId: id.projectId,
2216
3143
  history: historyForLlm
2217
3144
  });
2218
3145
  var run = this.dispatchAgentRequest({
2219
3146
  key,
2220
- serviceId: id.serviceId,
3147
+ projectId: id.projectId,
2221
3148
  owner: id.owner,
2222
3149
  aiPlatform,
2223
3150
  aiModel,
@@ -2274,6 +3201,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2274
3201
  if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
2275
3202
  if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
2276
3203
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
3204
+ if (existing._dimSending) promoted._dimSending = true;
3205
+ if (existing._localId !== void 0) promoted._localId = existing._localId;
2277
3206
  this.state.messages[nextIdx] = promoted;
2278
3207
  var placeholder = { role: "assistant", content: "", isPending: true };
2279
3208
  if (existing._serverItemId !== void 0) placeholder._serverItemId = existing._serverItemId;
@@ -2281,6 +3210,33 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2281
3210
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
2282
3211
  this.host.notify();
2283
3212
  }
3213
+ /**
3214
+ * The "Thinking..." placeholder belonging to the user bubble at `userIdx`, or -1.
3215
+ *
3216
+ * Every path that creates one puts it IMMEDIATELY after its user bubble
3217
+ * (promoteNextQueuedToRunning, the immediate-send pair, applyHistoryItemResolution),
3218
+ * so ownership is adjacency — modulo background bubbles, which get spliced in
3219
+ * around them. Taking the first pending assistant ANYWHERE below instead was a
3220
+ * hijack: a turn sent with attachments never gets a placeholder of its own
3221
+ * (promoteNextQueuedToRunning skips _useBgQueue turns) and now keeps the position
3222
+ * it was sent in, so an ordinary turn sent while its files indexed sits BELOW it
3223
+ * with a placeholder of its own — and the attachment turn's answer was rendered
3224
+ * as the answer to that unrelated question.
3225
+ */
3226
+ _ownThinkingIndex(userIdx, serverId) {
3227
+ if (userIdx < 0) return -1;
3228
+ for (var i = userIdx + 1; i < this.state.messages.length; i++) {
3229
+ var m = this.state.messages[i];
3230
+ if (!m) return -1;
3231
+ if (m.isBackgroundTask) continue;
3232
+ if (m.isPending && m.role === "assistant") {
3233
+ if (serverId && m._serverItemId && m._serverItemId !== serverId) return -1;
3234
+ return i;
3235
+ }
3236
+ return -1;
3237
+ }
3238
+ return -1;
3239
+ }
2284
3240
  resolveQueuedUserBubble(serverId) {
2285
3241
  var liveKey = this.getHistoryCacheKey();
2286
3242
  var isLocal = function(m) {
@@ -2307,9 +3263,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2307
3263
  if (userIdx >= 0) {
2308
3264
  var ex = this.state.messages[userIdx];
2309
3265
  this.state.messages[userIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...ex._ownerKey !== void 0 ? { _ownerKey: ex._ownerKey } : {} };
2310
- var thIdx = this.state.messages.findIndex(function(m, i) {
2311
- return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
2312
- });
3266
+ var thIdx = this._ownThinkingIndex(userIdx, serverId);
2313
3267
  if (thIdx !== -1) this.state.messages.splice(thIdx, 1);
2314
3268
  }
2315
3269
  this.promoteNextQueuedToRunning();
@@ -2321,16 +3275,17 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2321
3275
  if (exist._serverItemId !== void 0) repl._serverItemId = exist._serverItemId;
2322
3276
  if (exist._ownerKey !== void 0) repl._ownerKey = exist._ownerKey;
2323
3277
  if (exist._ts !== void 0) repl._ts = exist._ts;
3278
+ if (exist._localId !== void 0) repl._localId = exist._localId;
2324
3279
  this.state.messages[userIdx] = repl;
2325
3280
  }
2326
- var thinkingIdx = userIdx >= 0 ? this.state.messages.findIndex(function(m, i) {
2327
- return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
2328
- }) : -1;
3281
+ var thinkingIdx = this._ownThinkingIndex(userIdx, serverId);
2329
3282
  return thinkingIdx !== -1 ? thinkingIdx : userIdx >= 0 ? userIdx + 1 : -1;
2330
3283
  }
2331
3284
  insertAtTarget(msg, targetIdx) {
2332
3285
  if (msg && msg.role === "assistant" && msg._ts === void 0) msg._ts = wallClockNow();
2333
- if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
3286
+ var tgt = targetIdx >= 0 ? this.state.messages[targetIdx] : void 0;
3287
+ var replaceable = !!tgt && !!tgt.isPending && !tgt.isBackgroundTask && this._isOwnPlaceholderOf(targetIdx, this._owningUserIndex(targetIdx));
3288
+ if (replaceable) this.state.messages[targetIdx] = msg;
2334
3289
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
2335
3290
  else this.state.messages.push(msg);
2336
3291
  }
@@ -2447,16 +3402,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2447
3402
  var platform = id.platform;
2448
3403
  if (platform !== "claude" && platform !== "openai") return;
2449
3404
  var url = platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
2450
- var queueBase = id.userId || id.serviceId;
2451
- var queue = msg.isBackgroundTask || msg._useBgQueue ? queueBase + BG_INDEXING_QUEUE_SUFFIX : queueBase;
2452
- this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: void 0 });
3405
+ var queueBase = id.userId || id.projectId;
3406
+ var queue = msg.isBackgroundTask || msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
3407
+ var at = this.state.messages[idx] && this.state.messages[idx]._serverItemId === serverId && this.state.messages[idx].role === msg.role ? idx : this.state.messages.findIndex(function(m) {
3408
+ return m._serverItemId === serverId && m.role === msg.role;
3409
+ });
3410
+ if (at !== -1) {
3411
+ this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: void 0 });
3412
+ }
2453
3413
  this.host.notify();
2454
3414
  Promise.resolve(this.host.cancelRequest({
2455
3415
  url,
2456
3416
  method: "POST",
2457
3417
  id: serverId,
2458
3418
  queue,
2459
- service: id.serviceId,
3419
+ service: id.projectId,
2460
3420
  owner: id.owner
2461
3421
  })).then(function(result) {
2462
3422
  if (result && result.removed) {
@@ -2526,7 +3486,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2526
3486
  * 2. the file is remembered in cancelledIndexKeys, so the client-driven
2527
3487
  * resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
2528
3488
  * 3. any of its passes still sitting in bgTaskQueue is dropped by the next
2529
- * drain rather than surfacing a fresh "Indexing…" bubble.
3489
+ * drain rather than surfacing a fresh "Indexing…" bubble; and
3490
+ * 4. the RUN is remembered (state.stoppedIndexIds), because none of the above
3491
+ * necessarily leaves a mark on the conversation — see below — and without
3492
+ * it the collapsed row reported the stopped file as finished.
2530
3493
  *
2531
3494
  * Records already written by the passes that DID run are kept — this stops the
2532
3495
  * work, it does not undo it.
@@ -2536,6 +3499,19 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2536
3499
  if (!group || !group.key) return;
2537
3500
  var scoped = this.getHistoryCacheKey() + "|" + group.key;
2538
3501
  this.cancelledIndexKeys.add(scoped);
3502
+ if (!group.finished) {
3503
+ var stoppedIds = {};
3504
+ for (var sk in this.state.stoppedIndexIds) stoppedIds[sk] = true;
3505
+ (group.members || []).forEach(function(m) {
3506
+ var sid = m && m.msg && m.msg._serverItemId;
3507
+ if (sid) stoppedIds[sid] = true;
3508
+ });
3509
+ this.bgTaskQueue.forEach(function(e) {
3510
+ if (e && e.id && self._indexKeyOf(e) === scoped) stoppedIds[e.id] = true;
3511
+ });
3512
+ this.state.stoppedIndexIds = stoppedIds;
3513
+ }
3514
+ this._adoptWorkerIndexingPasses(0);
2539
3515
  var ids = group.cancellableIds || [];
2540
3516
  if (!ids.length) {
2541
3517
  this.host.notify();
@@ -2709,21 +3685,69 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2709
3685
  this.promoteNextQueuedToRunning();
2710
3686
  return this.enqueueTypewrite(pendingIdx, latest.content, lid);
2711
3687
  }
2712
- // Remove any leftover non-background pending ("Thinking…") assistant bubbles.
2713
- // There is normally at most ONE such bubble at a time (promoteNext* refuses to
2714
- // add a second), so any extra is a duplicate it appears when a concurrent
2715
- // history refetch re-maps the still-"running" turn into a pending placeholder
2716
- // (with a real _serverItemId) while the local pending bubble (no _serverItemId)
2717
- // is rescued and re-appended (see loadHistory rescue below). Each resolve path
2718
- // only replaces the FIRST pending bubble, so without this a stray "Thinking…"
2719
- // survives next to the reply/error. MUST run AFTER the resolved bubble has been
2720
- // made non-pending and BEFORE promoteNext*() (so a freshly-promoted Thinking,
2721
- // which is added only once no pending assistant remains, is preserved).
3688
+ // Remove leftover non-background pending ("Thinking…") assistant bubbles: the
3689
+ // duplicate that appears when a concurrent history refetch re-maps the still-
3690
+ // "running" turn into a pending placeholder (with a real _serverItemId) while the
3691
+ // local pending bubble (no _serverItemId) is rescued and re-appended (see the
3692
+ // loadHistory rescue below), and the orphan a resolve leaves when it splices its
3693
+ // reply beside a placeholder instead of into it. Each resolve path only replaces
3694
+ // ONE pending bubble, so without this a stray "Thinking…" survives forever next to
3695
+ // the reply. MUST run AFTER the resolved bubble has been made non-pending and
3696
+ // BEFORE promoteNext*() (which only adds a Thinking once none remains).
3697
+ //
3698
+ // It used to take EVERY one, on the premise that there is at most one at a time
3699
+ // because promoteNext* refuses to add a second. That premise never covered the
3700
+ // immediate-send path, which creates its pair directly — and a turn sent with
3701
+ // attachments does not block the composer and resolves on its own queue, so an
3702
+ // ordinary question asked while files index is in flight, with a placeholder of
3703
+ // its own, exactly when the attachment turn resolves. Sweeping it left that
3704
+ // question with no spinner and, worse, nowhere for its answer to land:
3705
+ // typewriteLatestReply bails when there is no pending assistant, so the reply
3706
+ // reached the cache and never the screen.
3707
+ //
3708
+ // The discriminator is the owning USER bubble. A live immediate send's user bubble
3709
+ // carries NO pending flags (its in-flight-ness lives in state.sending), while every
3710
+ // duplicate this sweep is for belongs to a user bubble that is still pending — and
3711
+ // an orphan has no user bubble above it at all.
2722
3712
  _removeStrayPendingAssistants() {
2723
3713
  for (var k = this.state.messages.length - 1; k >= 0; k--) {
2724
3714
  var m = this.state.messages[k];
2725
- if (m.isPending && m.role === "assistant" && !m.isBackgroundTask) this.state.messages.splice(k, 1);
2726
- }
3715
+ if (!m || !m.isPending || m.role !== "assistant" || m.isBackgroundTask) continue;
3716
+ if (this._isLiveImmediatePlaceholder(k)) continue;
3717
+ this.state.messages.splice(k, 1);
3718
+ }
3719
+ }
3720
+ /** Index of the USER bubble the message at `idx` belongs to — the nearest one
3721
+ * above it, stepping over background bubbles (a file's indexing rows are
3722
+ * inserted between turns). -1 when the nearest thing above is not a user turn,
3723
+ * which for a placeholder means it is an orphan. */
3724
+ _owningUserIndex(idx) {
3725
+ for (var j = idx - 1; j >= 0; j--) {
3726
+ var p = this.state.messages[j];
3727
+ if (!p) return -1;
3728
+ if (p.isBackgroundTask) continue;
3729
+ return p.role === "user" ? j : -1;
3730
+ }
3731
+ return -1;
3732
+ }
3733
+ /** The bubble at `idx` is the "Thinking…" of a DIFFERENT turn that is still
3734
+ * waiting for its answer, so the sweep above must leave it alone. */
3735
+ _isLiveImmediatePlaceholder(idx) {
3736
+ var ui = this._owningUserIndex(idx);
3737
+ if (ui === -1) return false;
3738
+ var p = this.state.messages[ui];
3739
+ return !p.isPending && !p.isPendingQueued && !p.isPendingInProcess && !p.isPendingOlder && !p.isSendingToServer && !p.isCancelled;
3740
+ }
3741
+ /** A pending assistant at `idx` is the placeholder OF the turn above it, so a
3742
+ * reply may take its slot. Every path that makes one copies the parent's
3743
+ * _serverItemId (or neither has one yet), so a mismatch means the slot belongs to
3744
+ * some other request and the reply must be spliced in beside it, not on top. */
3745
+ _isOwnPlaceholderOf(idx, userIdx) {
3746
+ if (userIdx === -1) return false;
3747
+ var ph = this.state.messages[idx], u = this.state.messages[userIdx];
3748
+ if (!ph || !u) return false;
3749
+ if (ph._serverItemId === void 0 || u._serverItemId === void 0) return true;
3750
+ return ph._serverItemId === u._serverItemId;
2727
3751
  }
2728
3752
  // Drop the pending flags on the resolved turn's USER bubble (preserving its
2729
3753
  // content + background-task marker). Needed because a bg "Indexing:" turn's user
@@ -2771,6 +3795,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2771
3795
  var indexRef = this._indexRefOfItem(itemId);
2772
3796
  this.applyHistoryItemResolution(itemId, response, platform);
2773
3797
  this.promoteNextBgQueuedToRunning();
3798
+ this.drainBgTaskQueue();
2774
3799
  if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
2775
3800
  }
2776
3801
  /** The file an already-rendered background pass is about, off its request
@@ -2784,11 +3809,67 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2784
3809
  }
2785
3810
  return null;
2786
3811
  }
3812
+ /**
3813
+ * Settle a turn the server reports as cancelled: the request bubble goes to its
3814
+ * cancelled form and the "Thinking..." placeholder goes away. The same shape
3815
+ * cancelQueuedMessage produces locally, so a cancel this client made and one it
3816
+ * merely found out about render identically — and an indexing pass keeps the
3817
+ * markers that hold it in its file's collapsed row.
3818
+ */
3819
+ _settleCancelledItem(itemId) {
3820
+ var uIdx = this.state.messages.findIndex(function(m) {
3821
+ return m.role === "user" && m._serverItemId === itemId && !m.isCancelled;
3822
+ });
3823
+ if (uIdx !== -1) {
3824
+ var u = this.state.messages[uIdx];
3825
+ var cancelled = { role: "user", content: u.content, isCancelled: true, _serverItemId: itemId };
3826
+ if (u.isBackgroundTask) cancelled.isBackgroundTask = true;
3827
+ if (u._indexFile) cancelled._indexFile = u._indexFile;
3828
+ if (u._useBgQueue) cancelled._useBgQueue = true;
3829
+ if (u._ownerKey !== void 0) cancelled._ownerKey = u._ownerKey;
3830
+ if (u._ts !== void 0) cancelled._ts = u._ts;
3831
+ this.state.messages[uIdx] = cancelled;
3832
+ }
3833
+ var pIdx = this.state.messages.findIndex(function(m) {
3834
+ return m.isPending && m.role === "assistant" && m._serverItemId === itemId;
3835
+ });
3836
+ if (pIdx !== -1) this.state.messages.splice(pIdx, 1);
3837
+ this.cancelledServerIds.delete(itemId);
3838
+ this._removeStrayPendingAssistants();
3839
+ this.host.notify();
3840
+ this.updateHistoryCache();
3841
+ }
3842
+ /**
3843
+ * A poll that came back saying the request was CANCELLED, rather than with an
3844
+ * answer.
3845
+ *
3846
+ * The server keeps a cancelled request as a terminal row instead of deleting it
3847
+ * (that row is the durable record of the stop, and the chat history it belongs
3848
+ * to), so a poll still running when the cancel lands now RESOLVES on it. It used
3849
+ * to reject with NOT_EXISTS, and the resolution path below reads a status object
3850
+ * as an answer with no text — which would stamp "No text response received from
3851
+ * AI provider" over a turn the user had just stopped.
3852
+ *
3853
+ * Reachable whenever the poll was not stopped by whoever cancelled: another tab,
3854
+ * another device, or the row being cancelled server-side by the file's own stop.
3855
+ */
3856
+ _isCancelledPollResult(response) {
3857
+ if (!response || typeof response !== "object" || response.status !== "cancelled") return false;
3858
+ if (response.content !== void 0 || response.output !== void 0) return false;
3859
+ return response.queue_name !== void 0 || response.in_queue !== void 0;
3860
+ }
2787
3861
  applyHistoryItemResolution(itemId, response, platform) {
2788
3862
  this.historyItemPolls.delete(itemId);
3863
+ if (this._isCancelledPollResult(response)) {
3864
+ this._settleCancelledItem(itemId);
3865
+ return;
3866
+ }
2789
3867
  var isErr = isErrorResponseBody(response);
2790
3868
  var answer = isErr ? getErrorMessage(response) : ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim();
2791
- if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join("").trim();
3869
+ var reportedComplete = !isErr && !!answer && answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
3870
+ var stripMarker = function(t) {
3871
+ return reportedComplete ? t.split(INDEXING_COMPLETE_MARKER).join("").trim() : t;
3872
+ };
2792
3873
  var idx = this.state.messages.findIndex(function(m) {
2793
3874
  return m.isPending && m._serverItemId === itemId;
2794
3875
  });
@@ -2804,7 +3885,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2804
3885
  }
2805
3886
  var text = answer || "No text response received from AI provider.";
2806
3887
  if (wasBgTask) {
2807
- this.state.messages[idx] = { role: "assistant", content: text, isBackgroundTask: true, _serverItemId: itemId };
3888
+ this.state.messages[idx] = { role: "assistant", content: stripMarker(text) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...reportedComplete ? { _indexComplete: true } : {} };
2808
3889
  this.host.notify();
2809
3890
  this.updateHistoryCache();
2810
3891
  return;
@@ -2837,7 +3918,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2837
3918
  }
2838
3919
  var text2 = answer || "No text response received from AI provider.";
2839
3920
  if (ex.isBackgroundTask) {
2840
- this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: text2, isBackgroundTask: true, _serverItemId: itemId });
3921
+ this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: stripMarker(text2) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...reportedComplete ? { _indexComplete: true } : {} });
2841
3922
  this.host.notify();
2842
3923
  this.updateHistoryCache();
2843
3924
  return;
@@ -2858,7 +3939,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2858
3939
  if (!entry) return "";
2859
3940
  var file = entry.storagePath || entry.filename;
2860
3941
  if (!file) return "";
2861
- return entry.serviceId + "#" + entry.platform + "|" + file;
3942
+ return entry.projectId + "#" + entry.platform + "|" + file;
2862
3943
  }
2863
3944
  /**
2864
3945
  * Reconcile the bg queue with the files the user has stopped.
@@ -2868,17 +3949,33 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2868
3949
  * path, and without this an earlier cancel would silently kill every future
2869
3950
  * index of the same path. A continuation of a stopped file is dropped instead,
2870
3951
  * covering the pass that was dispatched in the moment before the cancel landed.
3952
+ *
3953
+ * "Fresh" is the load-bearing word, and it used to be missing. A run's OWN first
3954
+ * pass sits in this queue for as long as it runs (entries are only dropped once
3955
+ * their bubble settles), so stopping a file during its first pass — which is
3956
+ * exactly when a user who has just uploaded it does — met that first-pass entry
3957
+ * on the very next drain and lifted the stop the user had just asked for. The
3958
+ * chain then carried on, one worker-minted window after another, with nothing
3959
+ * client-side left to suppress it. The ids recorded at stop time are what tells
3960
+ * the two apart: a pass that was already there when the user hit Stop cannot be
3961
+ * the new request that lifts it.
2871
3962
  */
2872
3963
  _applyIndexCancellations() {
2873
3964
  if (!this.cancelledIndexKeys.size) return;
3965
+ var surfaced = {};
3966
+ this.state.messages.forEach(function(m) {
3967
+ if (!m._serverItemId) return;
3968
+ if (m.isPending || m.isPendingQueued || m.isPendingInProcess) surfaced[m._serverItemId] = true;
3969
+ });
2874
3970
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
2875
3971
  var entry = this.bgTaskQueue[i];
2876
3972
  var key = this._indexKeyOf(entry);
2877
3973
  if (!key || !this.cancelledIndexKeys.has(key)) continue;
2878
- if (!entry.resumePass) {
3974
+ if (!entry.resumePass && !this.state.stoppedIndexIds[entry.id]) {
2879
3975
  this.cancelledIndexKeys.delete(key);
2880
3976
  continue;
2881
3977
  }
3978
+ if (surfaced[entry.id]) continue;
2882
3979
  this.bgTaskQueue.splice(i, 1);
2883
3980
  this._stopPoll(entry.id);
2884
3981
  this._cancelServerItem(entry.id);
@@ -2931,10 +4028,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2931
4028
  if (this._adoptingWorkerPasses) return;
2932
4029
  var id = this.host.getIdentity();
2933
4030
  var platform = id.platform;
2934
- if (!id.serviceId || platform !== "claude" && platform !== "openai") return;
4031
+ if (!id.projectId || platform !== "claude" && platform !== "openai") return;
2935
4032
  if (this.isPollingPaused() || !this.host.isViewMounted()) return;
2936
- var svcId = id.serviceId, owner = id.owner;
2937
- var queue = (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX;
4033
+ var svcId = id.projectId, owner = id.owner;
4034
+ var queue = bgIndexingQueueName(id.userId, id.projectId);
2938
4035
  var ask = function(status) {
2939
4036
  return Promise.resolve(getChatHistory(
2940
4037
  { service: svcId, owner, platform, queue, status },
@@ -2947,8 +4044,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2947
4044
  Promise.all([ask("running"), ask("pending")]).then(function(results) {
2948
4045
  self._adoptingWorkerPasses = false;
2949
4046
  var now = self.host.getIdentity();
2950
- if (now.serviceId !== svcId || now.platform !== platform) return;
4047
+ if (now.projectId !== svcId || now.platform !== platform) return;
2951
4048
  if (!self.host.isViewMounted()) return;
4049
+ if (results[0] !== null && results[1] !== null) self._recordLiveIndexKeys(results);
2952
4050
  var adoptedIds = [];
2953
4051
  for (var ri = 0; ri < results.length; ri++) {
2954
4052
  var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
@@ -2960,10 +4058,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2960
4058
  self.drainBgTaskQueue();
2961
4059
  if (self._isTrackingAny(adoptedIds)) return;
2962
4060
  }
2963
- if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) return;
4061
+ if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) {
4062
+ self._nudgeIndexingDrain();
4063
+ return;
4064
+ }
2964
4065
  setTimeout(function() {
2965
4066
  var later = self.host.getIdentity();
2966
- if (later.serviceId !== svcId || later.platform !== platform) return;
4067
+ if (later.projectId !== svcId || later.platform !== platform) return;
2967
4068
  if (self.isPollingPaused() || !self.host.isViewMounted()) return;
2968
4069
  self._adoptWorkerIndexingPasses(attempt + 1);
2969
4070
  }, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
@@ -3005,7 +4106,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3005
4106
  if (!ref || !ref.name) return false;
3006
4107
  if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
3007
4108
  this.bgTaskQueue.push({
3008
- serviceId: svcId,
4109
+ projectId: svcId,
3009
4110
  platform,
3010
4111
  id: item.id,
3011
4112
  filename: ref.name,
@@ -3036,8 +4137,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3036
4137
  url,
3037
4138
  method: "POST",
3038
4139
  id: serverId,
3039
- queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
3040
- service: id.serviceId,
4140
+ queue: bgIndexingQueueName(id.userId, id.projectId),
4141
+ service: id.projectId,
3041
4142
  owner: id.owner
3042
4143
  })).catch(function() {
3043
4144
  });
@@ -3046,7 +4147,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3046
4147
  drainBgTaskQueue() {
3047
4148
  var self = this;
3048
4149
  var id = this.host.getIdentity();
3049
- var svcId = id.serviceId, plat = id.platform;
4150
+ var svcId = id.projectId, plat = id.platform;
3050
4151
  if (!svcId || plat === "none" || !this.host.isViewMounted()) return;
3051
4152
  this._applyIndexCancellations();
3052
4153
  this._sweepCancelledIndexing();
@@ -3060,11 +4161,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3060
4161
  });
3061
4162
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
3062
4163
  var e = this.bgTaskQueue[i];
3063
- if (e.serviceId !== svcId || e.platform !== plat) continue;
4164
+ if (e.projectId !== svcId || e.platform !== plat) continue;
3064
4165
  if (presentIds[e.id] && !pendingIds[e.id]) this.bgTaskQueue.splice(i, 1);
3065
4166
  }
4167
+ var bgPollBudget = MAX_CONCURRENT_BG_POLLS - this._countBgPolls();
4168
+ var injectedAny = false;
3066
4169
  this.bgTaskQueue.forEach(function(entry) {
3067
- if (entry.serviceId !== svcId || entry.platform !== plat) return;
4170
+ if (entry.projectId !== svcId || entry.platform !== plat) return;
3068
4171
  if (!presentIds[entry.id]) {
3069
4172
  var isRunning = entry.status === "running";
3070
4173
  var userBubble = {
@@ -3085,16 +4188,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3085
4188
  };
3086
4189
  if (isRunning) userBubble.isPendingInProcess = true;
3087
4190
  else userBubble.isPendingQueued = true;
3088
- self.state.messages.push(userBubble);
3089
- if (isRunning) {
3090
- self.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
4191
+ var stageAt = self._stageIndex(self.state.messages, entry.stageId);
4192
+ var runningBubble = isRunning ? { role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id } : null;
4193
+ if (stageAt === -1) {
4194
+ self.state.messages.push(userBubble);
4195
+ if (runningBubble) self.state.messages.push(runningBubble);
4196
+ } else if (runningBubble) {
4197
+ self.state.messages.splice(stageAt, 0, userBubble, runningBubble);
4198
+ } else {
4199
+ self.state.messages.splice(stageAt, 0, userBubble);
3091
4200
  }
3092
4201
  presentIds[entry.id] = true;
3093
- self.host.notify();
3094
- self.updateHistoryCache();
3095
- self.host.scrollToBottomIfSticky(false);
4202
+ injectedAny = true;
3096
4203
  }
3097
- if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
4204
+ if (bgPollBudget > 0 && !self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
4205
+ bgPollBudget--;
3098
4206
  var capturedId = entry.id, capturedPlat = plat;
3099
4207
  var capturedEntry = entry;
3100
4208
  var wasStopped = false;
@@ -3127,15 +4235,24 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3127
4235
  }
3128
4236
  self.host.notify();
3129
4237
  self.updateHistoryCache();
4238
+ if (!self._isWorkerDrivenIndexing(capturedEntry.filename, capturedEntry.mime)) {
4239
+ self._nudgeIndexingDrain();
4240
+ }
3130
4241
  }).then(function() {
3131
4242
  if (wasStopped) return;
3132
4243
  var qi = self.bgTaskQueue.findIndex(function(q) {
3133
4244
  return q.id === capturedId;
3134
4245
  });
3135
4246
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
4247
+ self.drainBgTaskQueue();
3136
4248
  });
3137
4249
  }
3138
4250
  });
4251
+ if (injectedAny) {
4252
+ this.host.notify();
4253
+ this.updateHistoryCache();
4254
+ this.host.scrollToBottomIfSticky(false);
4255
+ }
3139
4256
  this.promoteNextBgQueuedToRunning();
3140
4257
  }
3141
4258
  // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
@@ -3152,25 +4269,45 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3152
4269
  // as well would now double-index every window.
3153
4270
  maybeResumeIndexing(entry, response, platform) {
3154
4271
  var self = this;
4272
+ var endOfClientChain = function() {
4273
+ self._nudgeIndexingDrain();
4274
+ };
3155
4275
  try {
3156
4276
  if (!entry || !entry.storagePath) return;
3157
4277
  if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
3158
- if (!isPagedReadFile(entry.filename, entry.mime)) return;
4278
+ if (!isPagedReadFile(entry.filename, entry.mime)) {
4279
+ endOfClientChain();
4280
+ return;
4281
+ }
3159
4282
  if (isImageVisionFile(entry.filename, entry.mime)) return;
3160
4283
  if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
3161
- if (isErrorResponseBody(response)) return;
4284
+ if (isErrorResponseBody(response)) {
4285
+ endOfClientChain();
4286
+ return;
4287
+ }
3162
4288
  var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
3163
- if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return;
4289
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) {
4290
+ endOfClientChain();
4291
+ return;
4292
+ }
3164
4293
  var pass = (entry.resumePass || 0) + 1;
3165
- if (pass > MAX_INDEXING_RESUME_PASSES) return;
4294
+ if (pass > MAX_INDEXING_RESUME_PASSES) {
4295
+ endOfClientChain();
4296
+ return;
4297
+ }
3166
4298
  var id = this.host.getIdentity();
3167
- if (!id || id.platform === "none" || id.serviceId !== entry.serviceId) return;
3168
- notifyAgentContinueIndexing({
4299
+ if (!id || id.platform === "none" || id.projectId !== entry.projectId) return;
4300
+ this.trackIndexDispatch(notifyAgentContinueIndexing({
3169
4301
  platform: id.platform,
3170
4302
  model: id.model,
3171
- service: id.serviceId,
4303
+ service: id.projectId,
4304
+ // Without this the resume pass rebuilds its system prompt from the RAW
4305
+ // regional id (requests.ts falls back to `service`), and the model copies
4306
+ // that id verbatim into project_id tool calls, which the MCP schema
4307
+ // pattern rejects - the whole continue pass saves nothing.
4308
+ publicProjectId: id.publicProjectId,
3172
4309
  owner: id.owner,
3173
- userId: id.userId || id.serviceId,
4310
+ userId: id.userId || id.projectId,
3174
4311
  serviceName: id.serviceName,
3175
4312
  serviceDescription: id.serviceDescription,
3176
4313
  attachment: {
@@ -3183,7 +4320,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3183
4320
  }).then(function(ack) {
3184
4321
  if (ack && typeof ack.id === "string") {
3185
4322
  self.bgTaskQueue.push({
3186
- serviceId: id.serviceId,
4323
+ projectId: id.projectId,
3187
4324
  platform: id.platform,
3188
4325
  id: ack.id,
3189
4326
  filename: entry.filename,
@@ -3194,12 +4331,19 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3194
4331
  status: ack.status === "running" ? "running" : "pending",
3195
4332
  poll: ack.poll,
3196
4333
  resumePass: pass
4334
+ // Deliberately NOT stamped with entry.stageId. Only a batch's FIRST
4335
+ // pass anchors to the turn; a continuation appends, which is the
4336
+ // order the server queued it in and therefore the order
4337
+ // promoteNextBgQueuedToRunning should spin it in. It costs nothing
4338
+ // on screen: a continuation is folded into the run whose row
4339
+ // already sits above the turn, and renders nothing at its own
4340
+ // index (indexing_groups anchors a run at its FIRST loaded pass).
3197
4341
  });
3198
4342
  self.drainBgTaskQueue();
3199
4343
  }
3200
4344
  }, function(e) {
3201
4345
  console.error("[chat-engine] resume-indexing dispatch failed", e);
3202
- });
4346
+ }));
3203
4347
  } catch (e) {
3204
4348
  }
3205
4349
  }
@@ -3212,21 +4356,25 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3212
4356
  loadHistory(fetchMore, token) {
3213
4357
  var self = this;
3214
4358
  var id = this.host.getIdentity();
3215
- var loadKey = !id.serviceId || id.platform === "none" ? "" : id.serviceId + "#" + id.platform;
4359
+ var loadKey = !id.projectId || id.platform === "none" ? "" : id.projectId + "#" + id.platform;
3216
4360
  if (token === void 0) token = this.state.gateRefreshToken;
3217
- if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.serviceId) {
4361
+ if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.projectId) {
3218
4362
  return Promise.resolve();
3219
4363
  }
3220
4364
  this.state.historyRequestToken = token;
3221
4365
  this.state.loadingHistory = true;
4366
+ if (!fetchMore && loadKey !== this._liveIndexKey) {
4367
+ this._liveIndexKey = loadKey;
4368
+ this._resetLiveIndexKeys();
4369
+ }
3222
4370
  if (fetchMore) this.state.loadingOlderHistory = true;
3223
4371
  this.host.notify();
3224
4372
  var platform = id.platform;
3225
- var serviceId = id.serviceId, owner = id.owner;
4373
+ var projectId = id.projectId, owner = id.owner;
3226
4374
  var options = { fetchMore };
3227
4375
  if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
3228
4376
  var fetchHistory = function() {
3229
- return getChatHistory({ service: serviceId, owner, platform }, options);
4377
+ return getChatHistory({ service: projectId, owner, platform }, options);
3230
4378
  };
3231
4379
  return Promise.resolve().then(fetchHistory).catch(function(err) {
3232
4380
  if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
@@ -3246,7 +4394,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3246
4394
  });
3247
4395
  var mapped = mapHistoryListToMessages(list, platform, {
3248
4396
  clearedAt: self.host.getClearedAt(),
3249
- serviceId: id.serviceId,
4397
+ projectId: id.projectId,
3250
4398
  formatIndexingLabel: self.host.formatIndexingLabel
3251
4399
  }).messages;
3252
4400
  var keptOlderPages = false;
@@ -3277,6 +4425,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3277
4425
  if (mm._ownerKey !== void 0 && mm._ownerKey !== loadKey) continue;
3278
4426
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
3279
4427
  if (!mm._serverItemId) {
4428
+ if (mm._stageId) {
4429
+ rescued.push(mm);
4430
+ continue;
4431
+ }
3280
4432
  if (mappedHasPendingAssistant) continue;
3281
4433
  if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
3282
4434
  else if (self.state.sending && mm.role === "user") {
@@ -3348,12 +4500,26 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3348
4500
  self.updateHistoryCache();
3349
4501
  self.host.notify();
3350
4502
  if (!fetchMore) {
4503
+ var bgAllow = {};
4504
+ var bgHistBudget = MAX_CONCURRENT_BG_POLLS - self._countBgPolls();
4505
+ if (bgHistBudget > 0) {
4506
+ var bgIds = chatList.filter(function(it) {
4507
+ if (it.status !== "running" && it.status !== "pending") return false;
4508
+ if (!it.poll || !it.id) return false;
4509
+ if (!(it._isBgTask || it._isOnBgQueue)) return false;
4510
+ return !self.historyItemPolls.has(it.id);
4511
+ }).map(function(it) {
4512
+ return it.id;
4513
+ }).sort();
4514
+ for (var ba = 0; ba < bgIds.length && ba < bgHistBudget; ba++) bgAllow[bgIds[ba]] = true;
4515
+ }
3351
4516
  chatList.forEach(function(item) {
3352
4517
  if (item.status !== "running" && item.status !== "pending") return;
3353
4518
  if (!item.poll || !item.id) return;
3354
4519
  if (self.historyItemPolls.has(item.id)) return;
3355
4520
  if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
3356
4521
  if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
4522
+ if ((item._isBgTask || item._isOnBgQueue) && !bgAllow[item.id]) return;
3357
4523
  var capturedId = item.id;
3358
4524
  var pp = item.poll({
3359
4525
  latency: POLL_INTERVAL,
@@ -3407,6 +4573,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3407
4573
  });
3408
4574
  self.drainBgTaskQueue();
3409
4575
  }
4576
+ if (!fetchMore) self.refreshLiveIndexState();
3410
4577
  if (!fetchMore) return self.host.scrollToBottomIfSticky();
3411
4578
  }).catch(function(err) {
3412
4579
  console.warn("[chat-engine] getChatHistory failed", err);
@@ -3424,7 +4591,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3424
4591
  // Upload one attachment (a file = 1 member, a folder = N) to db storage and
3425
4592
  // queue indexing per member. The bytes I/O + chip rendering go through host
3426
4593
  // hooks; the overwrite/reindex flow, status lifecycle, and indexing live here.
3427
- uploadSingleAttachment(att) {
4594
+ uploadSingleAttachment(att, stageId) {
3428
4595
  var self = this;
3429
4596
  var id = this.host.getIdentity();
3430
4597
  att.status = "uploading";
@@ -3471,6 +4638,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3471
4638
  return self.host.promptOverwrite(member.file.name).then(function(choice) {
3472
4639
  if (choice === "overwrite") {
3473
4640
  existedBefore = true;
4641
+ markImagePreviewStale(self.host.getIdentity().projectId || "default", member.storagePath);
3474
4642
  return doMemberUpload(false);
3475
4643
  }
3476
4644
  if (choice === "skip") {
@@ -3491,17 +4659,39 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3491
4659
  att.storagePath = member.storagePath;
3492
4660
  }
3493
4661
  var mime = member.file.type || self.host.getMimeType(member.file.name);
3494
- var preIndex = existedBefore && typeof self.host.deleteExistingFileRecord === "function" ? Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function() {
3495
- }) : Promise.resolve();
4662
+ var alreadyIndexing = false;
4663
+ var preIndex = self.claimIndexRun(member.storagePath).then(function(claimed) {
4664
+ alreadyIndexing = !claimed;
4665
+ if (alreadyIndexing) {
4666
+ console.log("[chat-engine] skipping a duplicate index request for", member.storagePath);
4667
+ return;
4668
+ }
4669
+ if (existedBefore && typeof self.host.deleteExistingFileRecord === "function") {
4670
+ return Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function() {
4671
+ });
4672
+ }
4673
+ });
4674
+ preIndex = preIndex.then(function() {
4675
+ if (alreadyIndexing) return;
4676
+ if (typeof self.host.ensureFileIndexRecord !== "function") return;
4677
+ return Promise.resolve(self.host.ensureFileIndexRecord(member.storagePath, {
4678
+ name: member.file.name,
4679
+ mime: mime || void 0,
4680
+ size: member.file.size
4681
+ })).catch(function() {
4682
+ });
4683
+ });
3496
4684
  return preIndex.then(function() {
3497
4685
  return parseAttachmentContent(member.file, member.file.name, mime || void 0);
3498
4686
  }).then(function(parsedContent) {
3499
- return notifyAgentSaveAttachment({
4687
+ if (alreadyIndexing) return;
4688
+ return self.trackIndexDispatch(notifyAgentSaveAttachment({
3500
4689
  platform: id.platform,
3501
4690
  model: id.model,
3502
- service: id.serviceId,
4691
+ service: id.projectId,
4692
+ publicProjectId: id.publicProjectId,
3503
4693
  owner: id.owner,
3504
- userId: id.userId || id.serviceId,
4694
+ userId: id.userId || id.projectId,
3505
4695
  serviceName: id.serviceName,
3506
4696
  serviceDescription: id.serviceDescription,
3507
4697
  attachment: {
@@ -3515,7 +4705,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3515
4705
  }).then(function(ack) {
3516
4706
  if (ack && typeof ack.id === "string") {
3517
4707
  self.bgTaskQueue.push({
3518
- serviceId: id.serviceId,
4708
+ projectId: id.projectId,
3519
4709
  platform: id.platform,
3520
4710
  id: ack.id,
3521
4711
  filename: member.file.name,
@@ -3524,18 +4714,23 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3524
4714
  mime: mime || void 0,
3525
4715
  size: member.file.size,
3526
4716
  status: ack.status === "running" ? "running" : "pending",
3527
- poll: ack.poll
4717
+ poll: ack.poll,
4718
+ // Puts this file's row directly above the chat turn it was
4719
+ // attached to (drainBgTaskQueue). Undefined for an
4720
+ // attachment-only send, which appends.
4721
+ stageId
3528
4722
  });
3529
4723
  self.drainBgTaskQueue();
3530
4724
  }
3531
4725
  }, function(e) {
3532
4726
  console.error("[chat-engine] indexing request failed", e);
4727
+ self.releaseIndexRun(member.storagePath);
3533
4728
  anyIndexFailed = true;
3534
4729
  if (!att.errorCode && !att.errorDetail) {
3535
4730
  att.errorCode = e && (e.code || e.body && e.body.code) || "";
3536
4731
  att.errorDetail = e && (e.message || e.body && e.body.message) || (typeof e === "string" ? e : "");
3537
4732
  }
3538
- });
4733
+ }));
3539
4734
  });
3540
4735
  });
3541
4736
  });
@@ -3554,14 +4749,28 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3554
4749
  }
3555
4750
  // Upload all not-yet-done attachments sequentially. Resolves to the full
3556
4751
  // list of { name, url, storagePath } for composing the chat message.
3557
- uploadPendingAttachments() {
4752
+ //
4753
+ // `batchId` scopes the run to the chips stamped with it at Send time. The
4754
+ // composer stays live during an upload, so by the time this runs the
4755
+ // attachment list can already hold chips the user picked for the NEXT
4756
+ // message — uploading those here would attach them to the wrong turn, and
4757
+ // collecting the previous batch's finished urls would attach files the user
4758
+ // already sent. Omitted (no batch) means every chip, the old behavior.
4759
+ //
4760
+ // `stageId` is the turn these chips were attached to, carried onto every indexing
4761
+ // task so its collapsed row renders directly ABOVE that turn's bubble (see
4762
+ // BgTaskEntry.stageId). Omitted for an attachment-only send, which has no turn.
4763
+ uploadPendingAttachments(batchId, stageId) {
3558
4764
  var self = this;
3559
4765
  this.host.resetOverwriteBatch();
4766
+ this._uploadBatches += 1;
3560
4767
  this.state.uploadingAttachments = true;
3561
4768
  this.host.updateComposerControls();
3562
4769
  this.host.renderAttachmentChips();
3563
4770
  var collected = [];
3564
- var snapshot = this.state.attachments.slice();
4771
+ var snapshot = this.state.attachments.filter(function(a) {
4772
+ return batchId ? a._batchId === batchId : true;
4773
+ });
3565
4774
  var chain = Promise.resolve();
3566
4775
  snapshot.forEach(function(att) {
3567
4776
  chain = chain.then(function() {
@@ -3580,7 +4789,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3580
4789
  return;
3581
4790
  }
3582
4791
  }
3583
- return self.uploadSingleAttachment(att).then(function(us) {
4792
+ return self.uploadSingleAttachment(att, stageId).then(function(us) {
3584
4793
  collected.push.apply(collected, us);
3585
4794
  }).catch(function(err) {
3586
4795
  var removed = !self.state.attachments.some(function(a) {
@@ -3597,7 +4806,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3597
4806
  });
3598
4807
  });
3599
4808
  var done = function() {
3600
- self.state.uploadingAttachments = false;
4809
+ self._uploadBatches = Math.max(0, self._uploadBatches - 1);
4810
+ self.state.uploadingAttachments = self._uploadBatches > 0;
3601
4811
  self.host.updateComposerControls();
3602
4812
  self.host.renderAttachmentChips();
3603
4813
  return collected;
@@ -3658,9 +4868,22 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3658
4868
  function isPendingMsg(m) {
3659
4869
  return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
3660
4870
  }
4871
+ function isHiddenPass(m) {
4872
+ if (m.role === "user") {
4873
+ if (m.isCancelled) return false;
4874
+ var ref = readFileRef(m);
4875
+ return !!(ref && ref.continued);
4876
+ }
4877
+ return !!m.isPending;
4878
+ }
3661
4879
  function buildChatDisplayList(messages, opts) {
3662
4880
  var list = Array.isArray(messages) ? messages : [];
4881
+ var liveIndexKeys = opts && opts.liveIndexKeys || {};
4882
+ var liveIndexChecked = !!(opts && opts.liveIndexChecked);
4883
+ var stoppedIndexIds = opts && opts.stoppedIndexIds || {};
4884
+ var windowedIndexing = opts && opts.windowedIndexing !== void 0 ? !!opts.windowedIndexing : windowedIndexingEnabled();
3663
4885
  var hasMoreHistory = !!(opts && opts.hasMoreHistory);
4886
+ var loadingOlderHistory = !!(opts && opts.loadingOlderHistory);
3664
4887
  var groups = {};
3665
4888
  var order = [];
3666
4889
  var runOfIndex = new Array(list.length);
@@ -3708,11 +4931,17 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3708
4931
  status: "done",
3709
4932
  cancellableIds: [],
3710
4933
  cancelling: false,
4934
+ stopped: false,
3711
4935
  mayHaveOlder: false,
3712
4936
  // The run's first loaded pass, and never re-stamped: see the file
3713
4937
  // docstring. `anchorId` is filled in once every member is known.
3714
4938
  anchorIndex: i,
3715
- anchorId: ""
4939
+ anchorId: "",
4940
+ // All five are derived once every member is known, below.
4941
+ visibleMembers: [],
4942
+ driver: "single",
4943
+ finished: false,
4944
+ resolving: false
3716
4945
  };
3717
4946
  order.push(runId);
3718
4947
  }
@@ -3730,6 +4959,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3730
4959
  if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
3731
4960
  if (ref && ref.name) keyByName[ref.name] = g.key;
3732
4961
  }
4962
+ var newestRunOfKey = {};
4963
+ for (var nk in runsOfKey) {
4964
+ var nrs = runsOfKey[nk];
4965
+ if (nrs.length) newestRunOfKey[nrs[nrs.length - 1]] = true;
4966
+ }
3733
4967
  for (var rk in runsOfKey) {
3734
4968
  var runIds = runsOfKey[rk];
3735
4969
  for (var ri = 0; ri < runIds.length; ri++) {
@@ -3753,6 +4987,19 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3753
4987
  break;
3754
4988
  }
3755
4989
  }
4990
+ var stopped = false;
4991
+ for (var ki = 0; ki < grp.members.length; ki++) {
4992
+ var km = grp.members[ki].msg;
4993
+ if (km.isCancelled) {
4994
+ stopped = true;
4995
+ break;
4996
+ }
4997
+ if (km._serverItemId && stoppedIndexIds[km._serverItemId]) {
4998
+ stopped = true;
4999
+ break;
5000
+ }
5001
+ }
5002
+ grp.stopped = stopped;
3756
5003
  for (var xi = 0; xi < grp.members.length; xi++) {
3757
5004
  if (grp.members[xi].msg._cancelling) {
3758
5005
  grp.cancelling = true;
@@ -3762,7 +5009,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3762
5009
  var seenIds = {};
3763
5010
  for (var ci = 0; ci < grp.members.length; ci++) {
3764
5011
  var cm = grp.members[ci].msg;
3765
- if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
5012
+ if (cm._cancelError && !stopped && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
3766
5013
  if (cm.role !== "user" || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
3767
5014
  if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
3768
5015
  if (ci < lastSettled) continue;
@@ -3772,9 +5019,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3772
5019
  }
3773
5020
  if (active) {
3774
5021
  grp.status = "active";
5022
+ if (stopped) grp.cancelling = true;
5023
+ } else if (stopped) {
5024
+ grp.status = "cancelled";
3775
5025
  } else {
3776
5026
  var last = grp.members[grp.members.length - 1].msg;
3777
- grp.status = last.isError ? "error" : last.isCancelled ? "cancelled" : "done";
5027
+ grp.status = last.isError ? "error" : "done";
3778
5028
  }
3779
5029
  var sawFirstPass = false;
3780
5030
  for (var pi = 0; pi < grp.members.length; pi++) {
@@ -3790,6 +5040,35 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3790
5040
  var anchor = grp.members[0];
3791
5041
  grp.anchorIndex = anchor.index;
3792
5042
  grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || "";
5043
+ var sawComplete = false;
5044
+ for (var vi = 0; vi < grp.members.length; vi++) {
5045
+ var vm = grp.members[vi];
5046
+ if (vm.msg._indexComplete) sawComplete = true;
5047
+ if (!isHiddenPass(vm.msg)) grp.visibleMembers.push(vm);
5048
+ }
5049
+ grp.driver = !isPagedReadFile(grp.name, grp.mime) ? "single" : isImageVisionFile(grp.name, grp.mime) ? "worker" : windowedIndexing ? "worker" : "client";
5050
+ if (grp.status === "active") {
5051
+ grp.finished = false;
5052
+ } else if (grp.status === "cancelled") {
5053
+ grp.finished = true;
5054
+ } else if (grp.driver === "single") {
5055
+ grp.finished = true;
5056
+ } else if (grp.driver === "client") {
5057
+ grp.finished = sawComplete || grp.status === "error" || grp.passCount >= MAX_INDEXING_RESUME_PASSES;
5058
+ } else {
5059
+ grp.finished = !newestRunOfKey[order[oi]] || liveIndexChecked && !liveIndexKeys[grp.key];
5060
+ }
5061
+ if (grp.status !== "done") {
5062
+ grp.resolving = false;
5063
+ } else if (grp.mayHaveOlder && loadingOlderHistory && !liveIndexKeys[grp.key] && newestRunOfKey[order[oi]]) {
5064
+ grp.resolving = true;
5065
+ grp.resolvingReason = "history";
5066
+ } else if (!grp.finished && grp.driver === "worker" && !liveIndexChecked && !liveIndexKeys[grp.key]) {
5067
+ grp.resolving = true;
5068
+ grp.resolvingReason = "status";
5069
+ } else {
5070
+ grp.resolving = false;
5071
+ }
3793
5072
  }
3794
5073
  var out = [];
3795
5074
  for (var j = 0; j < list.length; j++) {
@@ -3807,7 +5086,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3807
5086
  (function() {
3808
5087
  var MCP_PROD = "https://mcp.broadwayinc.computer";
3809
5088
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
3810
- var BQ_VERSION = "1.8.2" ;
5089
+ var BQ_VERSION = "1.8.3" ;
3811
5090
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
3812
5091
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
3813
5092
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -3962,7 +5241,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3962
5241
  // current UserProfile or null
3963
5242
  service: null,
3964
5243
  // resolved service info ({ ai_agent, name, ... })
3965
- serviceId: null,
5244
+ projectId: null,
3966
5245
  owner: null,
3967
5246
  theme: null,
3968
5247
  // agent config (read-only, admin-provided)
@@ -3976,7 +5255,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3976
5255
  // current view name
3977
5256
  };
3978
5257
  function skey(base) {
3979
- return base + ":" + (S.serviceId || "default");
5258
+ return base + ":" + (S.projectId || "default");
3980
5259
  }
3981
5260
  function loadTheme() {
3982
5261
  var stored = lsGet(SK.theme);
@@ -4023,7 +5302,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4023
5302
  });
4024
5303
  }
4025
5304
  function loadServiceInfo() {
4026
- S.serviceId = S.skapi && (S.skapi.service || S.skapi.connection && S.skapi.connection.service) || S.serviceId;
5305
+ S.projectId = S.skapi && (S.skapi.service || S.skapi.connection && S.skapi.connection.service) || S.projectId;
4027
5306
  S.owner = S.skapi && (S.skapi.owner || S.skapi.connection && S.skapi.connection.owner) || S.owner;
4028
5307
  return Promise.resolve().then(function() {
4029
5308
  if (typeof S.skapi.getConnectionInfo === "function") return S.skapi.getConnectionInfo();
@@ -4031,7 +5310,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4031
5310
  }).then(function(conn) {
4032
5311
  if (S.opts && S.opts.dev) console.log("[bunnyquery] loadServiceInfo", conn);
4033
5312
  if (conn) {
4034
- S.serviceId = conn.service || S.serviceId;
5313
+ S.projectId = conn.service || S.projectId;
4035
5314
  S.owner = conn.owner || S.owner;
4036
5315
  }
4037
5316
  return conn;
@@ -5390,8 +6669,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5390
6669
  // Rendered .bq-message nodes, indexed BY MESSAGE INDEX (sparse: a message
5391
6670
  // folded into a collapsed indexing row has no node of its own).
5392
6671
  messageEls: [],
5393
- // Expanded background-indexing rows, keyed by RUN (group.runKey), not by
5394
- // file: re-indexing a file is a separate row and expands separately.
6672
+ // Expanded background-indexing rows, keyed by FILE (group.key). Not by run:
6673
+ // runKey is renamed whenever an earlier pass of the run loads, which closed
6674
+ // a row the user had opened. Two runs of one file therefore share an open
6675
+ // state, which is the right reading of "show me this file's steps".
5395
6676
  indexGroupsOpen: {},
5396
6677
  messagesBox: null,
5397
6678
  // .bq-messages element
@@ -5402,6 +6683,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5402
6683
  stickToBottom: true,
5403
6684
  loadingHistory: false,
5404
6685
  loadingOlderHistory: false,
6686
+ // The viewport-fill LOOP is running (createHistoryFiller.onRunningChange).
6687
+ // Spans the gaps between its pages, where loadingOlderHistory keeps dropping
6688
+ // back to false; a collapsed indexing row that is still waiting for its own
6689
+ // earlier passes renders off this rather than flickering once per page.
6690
+ // View-side, not delegated to session.state: the filler is view-side too.
6691
+ historyFilling: false,
5405
6692
  historyEndOfList: false,
5406
6693
  historyStartKeyHistory: [],
5407
6694
  historyRequestToken: 0,
@@ -5433,19 +6720,38 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5433
6720
  var refreshingLinkMap = {};
5434
6721
  var refreshedExpiredLinkMap = {};
5435
6722
  var refreshingLinkPromises = /* @__PURE__ */ new Map();
6723
+ var unavailableLinkMap = {};
5436
6724
  var fileBlobCache = /* @__PURE__ */ new Map();
5437
6725
  var markedReady = null;
6726
+ function currentIdentity() {
6727
+ return {
6728
+ projectId: S.projectId,
6729
+ // Prefer the SDK's formatted token. The widget takes the page's own
6730
+ // skapi-js <script> pin, and builds older than 1.8.4 have no .project_id;
6731
+ // compose the formatted token exactly as buildSystemPrompt does, because
6732
+ // the raw regional id must never reach the indexing prompt - the model
6733
+ // copies it verbatim into project_id tool calls, which the MCP schema
6734
+ // pattern rejects.
6735
+ publicProjectId: S.skapi && S.skapi.project_id || (function() {
6736
+ if (S.projectId && S.owner && S.skapi && S.skapi.util && typeof S.skapi.util.formatServiceId === "function") {
6737
+ try {
6738
+ return S.skapi.util.formatServiceId(S.projectId, S.owner);
6739
+ } catch (e) {
6740
+ }
6741
+ }
6742
+ return void 0;
6743
+ })(),
6744
+ owner: S.owner,
6745
+ userId: S.user && S.user.user_id || S.projectId,
6746
+ platform: S.aiPlatform,
6747
+ model: S.aiModel || void 0,
6748
+ serviceName: S.serviceName,
6749
+ serviceDescription: S.serviceDescription
6750
+ };
6751
+ }
5438
6752
  var session = new ChatSession({
5439
6753
  getIdentity: function() {
5440
- return {
5441
- serviceId: S.serviceId,
5442
- owner: S.owner,
5443
- userId: S.user && S.user.user_id || S.serviceId,
5444
- platform: S.aiPlatform,
5445
- model: S.aiModel || void 0,
5446
- serviceName: S.serviceName,
5447
- serviceDescription: S.serviceDescription
5448
- };
6754
+ return currentIdentity();
5449
6755
  },
5450
6756
  buildSystemPrompt: function() {
5451
6757
  return buildSystemPrompt();
@@ -5494,6 +6800,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5494
6800
  deleteExistingFileRecord: function(path) {
5495
6801
  return deleteFileIndexRecordDb(path);
5496
6802
  },
6803
+ ensureFileIndexRecord: function(path, meta) {
6804
+ return ensureFileIndexRecordDb(path, meta);
6805
+ },
5497
6806
  storagePathFor: function(relPath) {
5498
6807
  return attachmentStoragePath(relPath);
5499
6808
  },
@@ -5600,8 +6909,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5600
6909
  return markedReady;
5601
6910
  }
5602
6911
  function buildSystemPrompt() {
6912
+ var promptProjectId = S.projectId || "";
6913
+ if (S.projectId && S.owner && S.skapi && S.skapi.util && typeof S.skapi.util.formatServiceId === "function") {
6914
+ try {
6915
+ promptProjectId = S.skapi.util.formatServiceId(S.projectId, S.owner);
6916
+ } catch (e) {
6917
+ }
6918
+ }
5603
6919
  return buildChatSystemPrompt({
5604
- formattedServiceId: S.serviceId || "",
6920
+ projectId: promptProjectId,
5605
6921
  serviceName: S.serviceName,
5606
6922
  serviceDescription: S.serviceDescription
5607
6923
  });
@@ -5615,13 +6931,57 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5615
6931
  return false;
5616
6932
  });
5617
6933
  }
6934
+ var attachmentUploadChain = Promise.resolve();
6935
+ var attachmentDispatchChain = Promise.resolve();
6936
+ var attachmentBatchSeq = 0;
6937
+ function enqueueAttachmentSend(job) {
6938
+ var uploaded = attachmentUploadChain.catch(function() {
6939
+ }).then(function() {
6940
+ return runAttachmentUpload(job);
6941
+ });
6942
+ attachmentUploadChain = uploaded;
6943
+ attachmentDispatchChain = attachmentDispatchChain.catch(function() {
6944
+ }).then(function() {
6945
+ return uploaded;
6946
+ }).then(function(urls) {
6947
+ return runAttachmentDispatch(job, urls);
6948
+ });
6949
+ }
6950
+ function runAttachmentUpload(job) {
6951
+ return session.uploadPendingAttachments(job.batchId, job.stageId).then(function(attachmentUrls) {
6952
+ var failureGroups = groupAttachmentFailures(CS.attachments.filter(function(a) {
6953
+ return a._batchId === job.batchId;
6954
+ }));
6955
+ clearSuccessfulAttachments(job.batchId);
6956
+ if (failureGroups.length) showUploadErrorReport(failureGroups);
6957
+ return attachmentUrls;
6958
+ }).catch(function(err) {
6959
+ console.error("[bunnyquery] attachment upload failed", err);
6960
+ updateComposerControls();
6961
+ renderAttachmentChips();
6962
+ if (job.stageId) session.settleStagedMessage(job.stageId);
6963
+ CS.messages.push({ role: "assistant", content: "Something went wrong while uploading attachments. " + (err && err.message || ""), isError: true });
6964
+ renderMessages();
6965
+ scrollToBottom(true);
6966
+ return null;
6967
+ });
6968
+ }
6969
+ function runAttachmentDispatch(job, attachmentUrls) {
6970
+ if (!attachmentUrls || !job.text) return Promise.resolve();
6971
+ if (job.stageId) session.markStagedMessageIndexing(job.stageId);
6972
+ return session.awaitIndexingDrained(job.pinned.identity).then(function() {
6973
+ if (job.stageId) session.markStagedMessageReady(job.stageId);
6974
+ var c = composeUserMessage(job.text, attachmentUrls);
6975
+ session.dispatchComposedMessage(c.composed, true, c.composedForLlm, c.extractContent, c.fileUrls, job.pinned);
6976
+ });
6977
+ }
5618
6978
  function sendMessage() {
5619
6979
  var inputEl = CS.messagesBox && CS.messagesBox.parentNode && CS.messagesBox.parentNode.querySelector(".bq-input");
5620
6980
  var text = (inputEl ? inputEl.value : "").trim();
5621
- var hasAttachments = CS.attachments.length > 0;
6981
+ var batchAttachments = composerAttachments();
6982
+ var hasAttachments = batchAttachments.length > 0;
5622
6983
  if (!text && !hasAttachments) return;
5623
6984
  if (!chatEnabled() || S.aiPlatform === "none") return;
5624
- if (CS.uploadingAttachments) return;
5625
6985
  recomputeAttachmentWarning();
5626
6986
  if (CS.attachmentWarning) {
5627
6987
  renderAttachmentChips();
@@ -5636,25 +6996,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5636
6996
  session.dispatchComposedMessage(text, false);
5637
6997
  return;
5638
6998
  }
5639
- var bgBefore = bgTaskQueue.length;
5640
- session.uploadPendingAttachments().then(function(attachmentUrls) {
5641
- var hasNewIndexing = bgTaskQueue.length > bgBefore;
5642
- var failureGroups = groupAttachmentFailures(CS.attachments);
5643
- clearSuccessfulAttachments();
5644
- if (text) {
5645
- var c = composeUserMessage(text, attachmentUrls);
5646
- session.dispatchComposedMessage(c.composed, hasNewIndexing, c.composedForLlm, c.extractContent, c.fileUrls);
5647
- }
5648
- if (failureGroups.length) showUploadErrorReport(failureGroups);
5649
- }).catch(function(err) {
5650
- console.error("[bunnyquery] attachment upload failed", err);
5651
- CS.uploadingAttachments = false;
5652
- updateComposerControls();
5653
- renderAttachmentChips();
5654
- CS.messages.push({ role: "assistant", content: "Something went wrong while uploading attachments. " + (err && err.message || ""), isError: true });
5655
- renderMessages();
5656
- scrollToBottom(true);
6999
+ attachmentBatchSeq += 1;
7000
+ var batchId = "batch_" + attachmentBatchSeq + "_" + Date.now();
7001
+ batchAttachments.forEach(function(a) {
7002
+ a._batchId = batchId;
5657
7003
  });
7004
+ recomputeAttachmentWarning();
7005
+ renderAttachmentChips();
7006
+ updateComposerControls();
7007
+ var stageId = text ? session.stageOutgoingMessage(text) : void 0;
7008
+ var pinned = {
7009
+ identity: currentIdentity(),
7010
+ systemPrompt: buildSystemPrompt(),
7011
+ stageId
7012
+ };
7013
+ enqueueAttachmentSend({ text, batchId, stageId, pinned });
5658
7014
  }
5659
7015
  function scrollToBottom(smooth) {
5660
7016
  return raf2().then(function() {
@@ -5703,30 +7059,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5703
7059
  var text = "\u2197 " + filename;
5704
7060
  return '<a class="bq-file-download" href="' + escapeHtml(href) + '" download="' + escapeHtml(filename) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(text) + "</a>";
5705
7061
  }
5706
- function linkToAnchorHtml(link) {
5707
- var refreshing = !!refreshingLinkMap[link.expiredHref || link.href];
5708
- var cls = ["bq-link-button"];
5709
- if (link.expired) cls.push("is-expired");
5710
- if (refreshing) cls.push("is-refreshing");
5711
- var labelText = "\u2197 " + link.label + (refreshing ? " (fetching...)" : "");
5712
- var attrs = [
5713
- 'class="' + cls.join(" ") + '"',
5714
- 'href="' + escapeHtml(link.href) + '"',
5715
- 'target="_blank"',
5716
- 'rel="noopener noreferrer"',
5717
- 'title="' + escapeHtml(link.fullLabel || link.label) + '"',
5718
- 'download="' + escapeHtml(link.fullLabel || link.label) + '"',
5719
- 'data-bq-link="1"'
5720
- ];
5721
- if (link.expired) attrs.push('data-bq-expired="1"');
5722
- if (link.expiredHref) attrs.push('data-bq-expired-href="' + escapeHtml(link.expiredHref) + '"');
5723
- if (link.remotePath) attrs.push('data-bq-remote-path="' + escapeHtml(link.remotePath) + '"');
5724
- if (link.fullLabel) attrs.push('data-bq-full-label="' + escapeHtml(link.fullLabel) + '"');
5725
- return "<a " + attrs.join(" ") + ">" + escapeHtml(labelText) + "</a>";
7062
+ function linkToAnchorHtml(link, allowImagePreview) {
7063
+ return renderInlineLinkHtml(link, {
7064
+ refreshing: !!refreshingLinkMap[link.expiredHref || link.href],
7065
+ allowImagePreview,
7066
+ unavailable: isLinkUnavailable(link, unavailableLinkMap)
7067
+ });
5726
7068
  }
5727
7069
  function buildLinkPartFromGroups(full, g1, g2, g3, g4, g5, g6) {
5728
7070
  return classifyInlineLink(full, [g1, g2, g3, g4, g5, g6], {
5729
- serviceId: S.serviceId,
7071
+ projectId: S.projectId,
5730
7072
  dbHostPrefix: "https://db." + hostDomain(),
5731
7073
  resolveFreshHref: function(expiredHref) {
5732
7074
  return refreshedExpiredLinkMap[expiredHref];
@@ -5761,12 +7103,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5761
7103
  codeMasks.push(match);
5762
7104
  return "\uE002C" + idx + "\uE003";
5763
7105
  });
7106
+ var previewsLeft = IMAGE_PREVIEWS_PER_MESSAGE;
5764
7107
  var linkRe = createInlineLinkRegex();
5765
7108
  working = working.replace(linkRe, function(full) {
5766
7109
  var args = Array.prototype.slice.call(arguments, 1, 7);
5767
7110
  var built = buildLinkPartFromGroups(full, args[0], args[1], args[2], args[3], args[4], args[5]);
5768
7111
  if (!built) return full;
5769
- return pushPlaceholder(linkToAnchorHtml(built.part)) + (built.tail || "");
7112
+ var allow = previewsLeft > 0;
7113
+ var html2 = linkToAnchorHtml(built.part, allow);
7114
+ if (allow && built.part.image) previewsLeft--;
7115
+ return pushPlaceholder(html2) + (built.tail || "");
5770
7116
  });
5771
7117
  working = working.replace(/C(\d+)/g, function(_m, idx) {
5772
7118
  return codeMasks[Number(idx)] || "";
@@ -5850,7 +7196,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5850
7196
  if (checkExistence === void 0) checkExistence = true;
5851
7197
  var params = {
5852
7198
  reserved_key: uploadReservedKey(),
5853
- service: S.serviceId,
7199
+ service: S.projectId,
5854
7200
  owner: S.owner,
5855
7201
  request: "db",
5856
7202
  key: storagePath,
@@ -5868,22 +7214,52 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5868
7214
  }
5869
7215
  function deleteFileIndexRecordDb(storagePath) {
5870
7216
  if (!storagePath || !S.skapi || typeof S.skapi.deleteRecords !== "function") return Promise.resolve();
5871
- return S.skapi.deleteRecords({ service: S.serviceId, unique_id: "src::" + storagePath }).catch(function() {
7217
+ return S.skapi.deleteRecords({ service: S.projectId, unique_id: "src::" + storagePath }).catch(function() {
5872
7218
  });
5873
7219
  }
5874
- function getTemporaryUrlDb(path, expires, cdn) {
7220
+ function ensureFileIndexRecordDb(storagePath, meta) {
7221
+ if (!storagePath || !S.skapi || typeof S.skapi.postRecord !== "function") return Promise.resolve();
7222
+ return Promise.resolve(S.skapi.postRecord(null, {
7223
+ service: S.projectId,
7224
+ unique_id: "src::" + storagePath,
7225
+ table: { name: "file_summaries", access_group: "authorized" },
7226
+ // Deleting the file record must cascade to every record referencing it.
7227
+ source: { can_remove_referencing_records: true },
7228
+ data: {
7229
+ file_name: meta && meta.name || storagePath.split("/").pop() || storagePath,
7230
+ storage_path: storagePath,
7231
+ mime_type: meta && meta.mime || null,
7232
+ size_bytes: meta && typeof meta.size === "number" ? meta.size : null,
7233
+ indexed_at: Date.now(),
7234
+ note: "File-level record created at upload. The indexing agent enriches this with sheet names, column headers and row counts."
7235
+ }
7236
+ })).catch(function() {
7237
+ });
7238
+ }
7239
+ function getTemporaryUrlDb(path, expires, cdn, contentType, opts) {
7240
+ opts = opts || {};
5875
7241
  var body = {
5876
- service: S.serviceId,
7242
+ service: S.projectId,
5877
7243
  owner: S.owner,
5878
7244
  request: "get-db",
5879
7245
  key: path,
5880
7246
  expires: expires || ATTACHMENT_URL_EXPIRES_SECONDS,
5881
- contentType: mimeGetType(path) || "application/octet-stream"
7247
+ contentType: contentType || mimeGetType(path) || "application/octet-stream"
5882
7248
  };
5883
7249
  if (cdn !== false) body.generate_temporary_cdn_url = true;
5884
- return S.skapi.util.request("get-signed-url", body, { auth: true, method: "post" }).then(function(res) {
7250
+ var reqOpts = { auth: true, method: "post" };
7251
+ if (cdn === false && opts.browserCache) {
7252
+ reqOpts.method = "get";
7253
+ reqOpts.stableGateway = true;
7254
+ if (opts.refresh) reqOpts.revalidate = true;
7255
+ body.browser_cache = opts.browserCache;
7256
+ var uid = S.user && S.user.user_id;
7257
+ if (uid) body.uid = uid;
7258
+ }
7259
+ return S.skapi.util.request("get-signed-url", body, reqOpts).then(function(res) {
5885
7260
  var u = typeof res === "string" ? res : res && res.url;
5886
7261
  if (!u) throw new Error("No temporary URL returned.");
7262
+ if (/^https?:\/\//i.test(u)) return u;
5887
7263
  return "https://db." + hostDomain() + "/" + u;
5888
7264
  });
5889
7265
  }
@@ -5908,9 +7284,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5908
7284
  if (IMAGE_EXTENSION_RE.test(name) || type.indexOf("image/") === 0) return ESTIMATED_IMAGE_TOKENS;
5909
7285
  return 0;
5910
7286
  }
7287
+ function composerAttachments() {
7288
+ return CS.attachments.filter(function(a) {
7289
+ return !a._batchId;
7290
+ });
7291
+ }
5911
7292
  function attachmentsTokenEstimate() {
5912
7293
  var total = 0;
5913
- CS.attachments.forEach(function(a) {
7294
+ composerAttachments().forEach(function(a) {
5914
7295
  if (a.kind === "folder") {
5915
7296
  (a.files || []).forEach(function(f) {
5916
7297
  total += estimateFileTokenCost(f.file);
@@ -5921,7 +7302,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5921
7302
  }
5922
7303
  function attachmentFileCount() {
5923
7304
  var n = 0;
5924
- CS.attachments.forEach(function(a) {
7305
+ composerAttachments().forEach(function(a) {
5925
7306
  n += a.kind === "folder" ? a.files ? a.files.length : 0 : 1;
5926
7307
  });
5927
7308
  return n;
@@ -6145,27 +7526,22 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6145
7526
  scheduleAttachmentOverflowRecompute();
6146
7527
  }
6147
7528
  function clearAttachments() {
6148
- CS.attachments.forEach(function(a) {
6149
- if (a._abort) {
6150
- try {
6151
- a._abort();
6152
- } catch (e) {
6153
- }
6154
- }
7529
+ CS.attachments = CS.attachments.filter(function(a) {
7530
+ return !!a._batchId;
6155
7531
  });
6156
- CS.attachments = [];
6157
7532
  CS.attachmentWarning = "";
6158
7533
  CS.attachmentCapNotice = "";
6159
7534
  renderAttachmentChips();
6160
7535
  updateComposerControls();
6161
7536
  scheduleAttachmentOverflowRecompute();
6162
7537
  }
6163
- function clearSuccessfulAttachments() {
7538
+ function clearSuccessfulAttachments(batchId) {
6164
7539
  CS.attachments = CS.attachments.filter(function(a) {
6165
- return a.status === "error" || a.status === "indexError";
6166
- });
6167
- CS.attachments.forEach(function(a) {
7540
+ if (a._batchId !== batchId) return true;
7541
+ if (a.status !== "error" && a.status !== "indexError") return false;
6168
7542
  a._abort = null;
7543
+ delete a._batchId;
7544
+ return true;
6169
7545
  });
6170
7546
  CS.attachmentCapNotice = "";
6171
7547
  recomputeAttachmentWarning();
@@ -6230,7 +7606,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6230
7606
  var meta = att.status === "error" ? "(Failed)" : att.status === "indexError" ? "(Error)" : att.status === "uploading" ? (att.progress || 0) + "%" : isFolder ? "(" + (att.files ? att.files.length : 0) + ")" : formatBytes(att.file ? att.file.size : att.size);
6231
7607
  chip.appendChild(h("span", { class: "bq-attachment-meta", text: meta }));
6232
7608
  if (clickable) chip.appendChild(h("span", { class: "bq-attachment-arrow", text: "\u2197" }));
6233
- if (!CS.uploadingAttachments && att.status !== "done") {
7609
+ if (att.status !== "uploading" && att.status !== "done") {
6234
7610
  var rm = h("button", { class: "bq-attachment-remove", type: "button", title: "Remove", text: "\xD7" });
6235
7611
  rm.addEventListener("click", function(e) {
6236
7612
  e.stopPropagation();
@@ -6250,23 +7626,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6250
7626
  title: moreNames.join("\n")
6251
7627
  });
6252
7628
  moreChip.appendChild(h("span", { class: "bq-attachment-name", text: "\u2026(" + hidden.length + ") more" }));
6253
- if (!CS.uploadingAttachments) {
6254
- var moreRm = h("button", {
6255
- class: "bq-attachment-remove",
6256
- type: "button",
6257
- title: "Remove these " + hidden.length,
6258
- text: "\xD7"
6259
- });
6260
- moreRm.addEventListener("click", function(e) {
6261
- e.stopPropagation();
6262
- removeAttachments(hidden.map(function(a) {
6263
- return a.id;
6264
- }));
6265
- });
6266
- moreChip.appendChild(moreRm);
6267
- }
7629
+ var moreRm = h("button", {
7630
+ class: "bq-attachment-remove",
7631
+ type: "button",
7632
+ title: "Remove these " + hidden.length,
7633
+ text: "\xD7"
7634
+ });
7635
+ moreRm.addEventListener("click", function(e) {
7636
+ e.stopPropagation();
7637
+ removeAttachments(hidden.map(function(a) {
7638
+ return a.id;
7639
+ }));
7640
+ });
7641
+ moreChip.appendChild(moreRm);
6268
7642
  row.appendChild(moreChip);
6269
- } else if (!CS.uploadingAttachments && CS.attachments.length >= 2) {
7643
+ } else if (composerAttachments().length >= 2) {
6270
7644
  var removeAll = h("button", {
6271
7645
  class: "bq-attachment-remove-all",
6272
7646
  type: "button",
@@ -6280,16 +7654,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6280
7654
  }
6281
7655
  }
6282
7656
  function uploadsFrozenForUser() {
7657
+ if (!S.user) return true;
6283
7658
  var conf = S.service && S.service.conf || {};
6284
7659
  if (!conf.freeze_database) return false;
6285
7660
  var ag = S.user && typeof S.user.access_group === "number" ? S.user.access_group : 0;
6286
7661
  return ag < 99;
6287
7662
  }
6288
7663
  function updateComposerControls() {
6289
- var uploading = CS.uploadingAttachments;
6290
- if (CS.attachBtnEl) CS.attachBtnEl.disabled = uploading;
6291
- if (CS.inputEl) CS.inputEl.disabled = uploading;
6292
- if (CS.sendBtnEl) CS.sendBtnEl.disabled = uploading || !!CS.attachmentWarning;
7664
+ if (CS.attachBtnEl) CS.attachBtnEl.disabled = false;
7665
+ if (CS.inputEl) CS.inputEl.disabled = false;
7666
+ if (CS.sendBtnEl) CS.sendBtnEl.disabled = !!CS.attachmentWarning;
6293
7667
  }
6294
7668
  function onAttachInputChange(inputEl) {
6295
7669
  if (inputEl && inputEl.files && inputEl.files.length) addFilesToAttachments(inputEl.files);
@@ -6387,9 +7761,54 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6387
7761
  if (!remotePath) return Promise.reject(new Error("Missing attachment path."));
6388
7762
  return getTemporaryUrlDb(remotePath, EXPIRED_LINK_REFRESH_EXPIRES_SECONDS, false);
6389
7763
  }
7764
+ var unavailableRepaintQueued = false;
7765
+ function markLinkUnavailable(key) {
7766
+ if (!key || unavailableLinkMap[key]) return;
7767
+ unavailableLinkMap[key] = true;
7768
+ if (!refreshedLinkExpiryTimer) scheduleNextLinkExpiryBoundary();
7769
+ if (unavailableRepaintQueued) return;
7770
+ unavailableRepaintQueued = true;
7771
+ setTimeout(function() {
7772
+ unavailableRepaintQueued = false;
7773
+ renderMessages();
7774
+ }, 0);
7775
+ }
7776
+ function imagePreviewCtx() {
7777
+ return {
7778
+ scope: S.projectId || "default",
7779
+ mint: function(remotePath, contentType, refresh) {
7780
+ return getTemporaryUrlDb(remotePath, EXPIRED_LINK_REFRESH_EXPIRES_SECONDS, false, contentType, {
7781
+ browserCache: PREVIEW_BROWSER_CACHE_SECONDS,
7782
+ refresh
7783
+ });
7784
+ },
7785
+ // An image arriving late pushes the conversation down under the
7786
+ // viewport. Re-pin only if the user was already at the bottom.
7787
+ onLoad: function() {
7788
+ scrollToBottomIfSticky(false);
7789
+ },
7790
+ // The mint was refused, or the url it minted would not load. Either
7791
+ // way there is no url for this file, so the caption chip left behind
7792
+ // must not keep offering a click that opens a dead tab.
7793
+ onError: function(path, err) {
7794
+ console.warn("[bunnyquery] image preview failed", path, err);
7795
+ markLinkUnavailable(linkUnavailableKeyForPath(path));
7796
+ }
7797
+ };
7798
+ }
7799
+ function hydrateMessageImagePreviews() {
7800
+ if (!CS.messagesBox) return;
7801
+ var nodes = CS.messagesBox.querySelectorAll("img.bq-img-preview:not([data-bq-img-state])");
7802
+ if (!nodes.length) return;
7803
+ var list = Array.prototype.filter.call(nodes, function(n) {
7804
+ return !(n.closest && n.closest(".bq-index-label"));
7805
+ });
7806
+ if (list.length) hydrateImagePreviews(list, imagePreviewCtx());
7807
+ }
6390
7808
  var refreshedLinkExpiryTimer = null;
6391
7809
  function expireAllRefreshedLinks() {
6392
7810
  for (var k in refreshedExpiredLinkMap) delete refreshedExpiredLinkMap[k];
7811
+ for (var u in unavailableLinkMap) delete unavailableLinkMap[u];
6393
7812
  }
6394
7813
  function scheduleNextLinkExpiryBoundary() {
6395
7814
  if (refreshedLinkExpiryTimer) clearTimeout(refreshedLinkExpiryTimer);
@@ -6408,7 +7827,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6408
7827
  if (inFlight) return inFlight;
6409
7828
  var run = (function() {
6410
7829
  refreshingLinkMap[expiredHref] = true;
6411
- var resolved = remotePath || extractRemotePathFromAttachmentHref(expiredHref, S.serviceId);
7830
+ var resolved = remotePath || extractRemotePathFromAttachmentHref(expiredHref, S.projectId);
6412
7831
  if (!resolved) return Promise.reject(new Error("Unable to refresh this expired attachment link."));
6413
7832
  return getPublicTemporaryUrl(resolved).then(function(fresh) {
6414
7833
  refreshedExpiredLinkMap[expiredHref] = fresh;
@@ -6435,6 +7854,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6435
7854
  if (!target) return;
6436
7855
  var anchor = target.closest ? target.closest("a[data-bq-link]") : null;
6437
7856
  if (!anchor) return;
7857
+ if (anchor.dataset.bqUnavailable === "1") {
7858
+ e.preventDefault();
7859
+ return;
7860
+ }
6438
7861
  if (anchor.dataset.bqExpired !== "1") return;
6439
7862
  e.preventDefault();
6440
7863
  var originalHref = anchor.dataset.bqExpiredHref || anchor.href;
@@ -6452,12 +7875,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6452
7875
  anchor.click();
6453
7876
  }).catch(function(err) {
6454
7877
  console.error("[bunnyquery] expired link refresh failed", err);
6455
- alert(err && err.message || "Failed to refresh this expired link.");
7878
+ markLinkUnavailable(linkUnavailableKeyForHref(originalHref));
7879
+ if (anchor.dataset.bqRemotePath) markLinkUnavailable(linkUnavailableKeyForPath(anchor.dataset.bqRemotePath));
6456
7880
  });
6457
7881
  }
6458
7882
  function getClearHistoryStorageKey() {
6459
- if (!S.serviceId || S.aiPlatform === "none") return "";
6460
- return SK.clearHorizon + ":" + S.serviceId + "#" + S.aiPlatform;
7883
+ if (!S.projectId || S.aiPlatform === "none") return "";
7884
+ return SK.clearHorizon + ":" + S.projectId + "#" + S.aiPlatform;
6461
7885
  }
6462
7886
  function getClearedAt() {
6463
7887
  var key = getClearHistoryStorageKey();
@@ -6522,6 +7946,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6522
7946
  },
6523
7947
  fetchOlder: function() {
6524
7948
  return fetchOlderHistoryIfNeeded();
7949
+ },
7950
+ // A collapsed indexing row whose run begins above the loaded window says
7951
+ // "loading" for as long as the pages that would complete it keep coming, and
7952
+ // that span is the LOOP, not a page (see createHistoryFiller). Rendering the
7953
+ // flip is safe from here: renderMessages never starts a fill of its own, and
7954
+ // the loop's own guard has already flipped before this is called.
7955
+ onRunningChange: function(running) {
7956
+ CS.historyFilling = running;
7957
+ renderMessages();
6525
7958
  }
6526
7959
  });
6527
7960
  function pageOlderHistoryUntil(isSatisfied, token) {
@@ -6606,7 +8039,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6606
8039
  if (msg.isError) cls.push("is-error");
6607
8040
  if (msg.isCancelled) cls.push("is-cancelled");
6608
8041
  if (msg.isPendingQueued || msg.isPendingOlder) cls.push("is-pending-older");
6609
- if (msg.isSendingToServer || msg._cancelling) cls.push("is-sending-to-server");
8042
+ if (msg._dimSending || msg._cancelling) cls.push("is-sending-to-server");
6610
8043
  var bubble;
6611
8044
  if (msg.isPending) {
6612
8045
  bubble = h("div", { class: "bq-bubble" }, h("span", { class: "bq-loader" }));
@@ -6629,7 +8062,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6629
8062
  var md = h("div", { class: "bq-md", translate: "no", html: parseMsgPartsHtml(msg.content) });
6630
8063
  md.addEventListener("click", onBubbleLinkClick);
6631
8064
  bubble.appendChild(md);
6632
- if (msg.isPendingQueued) bubble.appendChild(h("span", { class: "bq-pending-note", text: "(In queue)" }));
8065
+ if (msg.isUploadingAttachments) bubble.appendChild(h("span", { class: "bq-pending-note", text: "(Uploading files...)" }));
8066
+ else if (msg.isAwaitingIndexing) bubble.appendChild(h("span", { class: "bq-pending-note", text: "(Indexing files...)" }));
8067
+ else if (msg.isPendingQueued) bubble.appendChild(h("span", { class: "bq-pending-note", text: "(In queue)" }));
6633
8068
  if (msg.isCancelled) bubble.appendChild(h("span", { class: "bq-cancel-error", text: "(cancelled)" }));
6634
8069
  if (msg._cancelError) bubble.appendChild(h("span", { class: "bq-cancel-error", text: msg._cancelError }));
6635
8070
  var ts = formatChatTimestamp(msg._ts);
@@ -6641,16 +8076,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6641
8076
  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>';
6642
8077
  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>';
6643
8078
  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>';
8079
+ var INDEX_ICON_PENDING = '<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.4V12l3.3 2"/></svg>';
6644
8080
  function indexGroupIcon(group) {
6645
- if (group.status === "active") return INDEX_ICON_ACTIVE;
6646
- if (group.status === "error") return INDEX_ICON_ERROR;
6647
8081
  if (group.status === "cancelled") return INDEX_ICON_CANCELLED;
8082
+ if (group.resolving) return INDEX_ICON_PENDING;
8083
+ if (!group.finished) return INDEX_ICON_ACTIVE;
8084
+ if (group.status === "error") return INDEX_ICON_ERROR;
6648
8085
  return INDEX_ICON_DONE;
6649
8086
  }
6650
8087
  function indexGroupVerb(group) {
6651
- if (group.status === "active") return group.isReindex ? "Reindexing" : "Indexing";
6652
- if (group.status === "error") return "Indexing failed:";
6653
8088
  if (group.status === "cancelled") return "Indexing cancelled:";
8089
+ if (group.resolving) {
8090
+ return group.resolvingReason === "history" ? "Loading history:" : "Checking status:";
8091
+ }
8092
+ if (!group.finished) return group.isReindex ? "Reindexing" : "Indexing";
8093
+ if (group.status === "error") return "Indexing failed:";
6654
8094
  return group.isReindex ? "Reindexed" : "Indexed";
6655
8095
  }
6656
8096
  function indexGroupLabel(group) {
@@ -6661,6 +8101,102 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6661
8101
  if (group.passCount <= 1 && !group.mayHaveOlder) return "";
6662
8102
  return group.passCount + (group.mayHaveOlder ? "+" : "") + " passes";
6663
8103
  }
8104
+ function displayListOptions() {
8105
+ var liveIndex = session.getLiveIndexState();
8106
+ return {
8107
+ hasMoreHistory: !CS.historyEndOfList,
8108
+ // Older pages coming in RIGHT NOW. CS.historyFilling, not just the
8109
+ // per-request flag: the viewport fill is many pages and that flag drops
8110
+ // to false between every one of them, which is once per page of flicker
8111
+ // on any row rendered off it.
8112
+ loadingOlderHistory: !!(CS.loadingOlderHistory || CS.historyFilling),
8113
+ liveIndexKeys: liveIndex.keys,
8114
+ liveIndexChecked: liveIndex.checked,
8115
+ // Runs the user stopped. A stop that landed on a RUNNING pass leaves no
8116
+ // cancelled bubble behind (that pass finishes and answers normally), so
8117
+ // without this the row reports the stop as a finished "Indexed".
8118
+ stoppedIndexIds: session.getStoppedIndexIds()
8119
+ };
8120
+ }
8121
+ var stopIndexState = { runKey: "", fileKey: "", handle: null };
8122
+ function indexGroupStoppable(group) {
8123
+ return !!group && !group.finished && !group.resolving && !group.stopped && !group.cancelling;
8124
+ }
8125
+ function findCancellableIndexGroup(runKey, fileKey) {
8126
+ if (!runKey) return null;
8127
+ var list = buildChatDisplayList(CS.messages, displayListOptions());
8128
+ var byFile = null;
8129
+ for (var i = 0; i < list.length; i++) {
8130
+ var row = list[i];
8131
+ if (row.kind !== "indexing") continue;
8132
+ var live = indexGroupStoppable(row.group) ? row.group : null;
8133
+ if (row.group.runKey === runKey) return live;
8134
+ if (!byFile && live && fileKey && row.group.key === fileKey) byFile = row.group;
8135
+ }
8136
+ return byFile;
8137
+ }
8138
+ function stopIndexModalIsOpen() {
8139
+ var hnd = stopIndexState.handle;
8140
+ if (hnd && hnd.root && hnd.root.parentNode) return true;
8141
+ stopIndexState.runKey = "";
8142
+ stopIndexState.fileKey = "";
8143
+ stopIndexState.handle = null;
8144
+ return false;
8145
+ }
8146
+ function closeStopIndexModal() {
8147
+ var hnd = stopIndexState.handle;
8148
+ stopIndexState.runKey = "";
8149
+ stopIndexState.fileKey = "";
8150
+ stopIndexState.handle = null;
8151
+ if (hnd) hnd.close();
8152
+ }
8153
+ function syncStopIndexModal() {
8154
+ if (!stopIndexModalIsOpen()) return;
8155
+ if (!findCancellableIndexGroup(stopIndexState.runKey, stopIndexState.fileKey)) closeStopIndexModal();
8156
+ }
8157
+ function openStopIndexModal(group) {
8158
+ if (!indexGroupStoppable(group)) return;
8159
+ closeStopIndexModal();
8160
+ stopIndexState.runKey = group.runKey;
8161
+ stopIndexState.fileKey = group.key;
8162
+ var name = group.name;
8163
+ stopIndexState.handle = openModal(function(close) {
8164
+ var stopBtn = h("button", { class: "btn btn--danger", type: "button" }, "Stop indexing");
8165
+ stopBtn.addEventListener("click", function() {
8166
+ var runKey = stopIndexState.runKey;
8167
+ var fileKey = stopIndexState.fileKey;
8168
+ closeStopIndexModal();
8169
+ var live = findCancellableIndexGroup(runKey, fileKey);
8170
+ if (live) session.cancelIndexingGroup(live);
8171
+ });
8172
+ return h(
8173
+ "div",
8174
+ { class: "bq-modal" },
8175
+ h("button", { class: "bq-modal-close", type: "button", html: "&times;", onclick: close }),
8176
+ h("div", { class: "bq-modal-delete-header" }, h("span", { text: "Stop indexing" })),
8177
+ // The file name is user data: translate="no" keeps a browser
8178
+ // translator from rewriting it (agent.vue tags it the same way).
8179
+ h(
8180
+ "p",
8181
+ { class: "bq-modal-desc" },
8182
+ "Stop indexing \u201C",
8183
+ h("span", { translate: "no", text: name }),
8184
+ "\u201D?"
8185
+ ),
8186
+ h(
8187
+ "p",
8188
+ { class: "bq-modal-desc bq-modal-delete-warn" },
8189
+ "Whatever has been indexed so far stays searchable, and the pass already running finishes on the server. The remaining passes are dropped, not paused, so the file stays partly indexed until you reindex it."
8190
+ ),
8191
+ h(
8192
+ "div",
8193
+ { class: "bq-modal-btns" },
8194
+ h("button", { class: "btn btn--outline", type: "button", onclick: close }, "Keep indexing"),
8195
+ stopBtn
8196
+ )
8197
+ );
8198
+ });
8199
+ }
6664
8200
  function toggleIndexGroup(key) {
6665
8201
  if (CS.indexGroupsOpen[key]) delete CS.indexGroupsOpen[key];
6666
8202
  else CS.indexGroupsOpen[key] = true;
@@ -6669,8 +8205,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6669
8205
  }
6670
8206
  function buildIndexGroupEl(group, isOpen) {
6671
8207
  var cls = ["bq-index-group"];
6672
- if (group.status === "active") cls.push("is-active");
6673
- if (group.status === "error") cls.push("is-error");
8208
+ if (group.resolving) cls.push("is-resolving");
8209
+ if (!group.resolving && !group.finished && group.status !== "cancelled") cls.push("is-active");
8210
+ if (!group.resolving && group.finished && group.status === "done") cls.push("is-indexed");
8211
+ if (group.finished && group.status === "error") cls.push("is-error");
6674
8212
  if (isOpen) cls.push("is-open");
6675
8213
  var label = h("span", { class: "bq-index-label", html: parseMsgPartsHtml(indexGroupLabel(group)) });
6676
8214
  label.addEventListener("click", function(e) {
@@ -6678,7 +8216,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6678
8216
  onBubbleLinkClick(e);
6679
8217
  });
6680
8218
  var cancelBtn = null;
6681
- if (group.cancellableIds.length || group.cancelling) {
8219
+ if (indexGroupStoppable(group) || group.cancelling) {
6682
8220
  cancelBtn = h("button", {
6683
8221
  class: "bq-index-cancel" + (group.cancelling ? " is-disabled" : ""),
6684
8222
  type: "button",
@@ -6689,7 +8227,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6689
8227
  if (group.cancelling) cancelBtn.disabled = true;
6690
8228
  else cancelBtn.addEventListener("click", function(e) {
6691
8229
  e.stopPropagation();
6692
- session.cancelIndexingGroup(group);
8230
+ openStopIndexModal(group);
6693
8231
  });
6694
8232
  cancelBtn.addEventListener("keydown", function(e) {
6695
8233
  e.stopPropagation();
@@ -6704,12 +8242,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6704
8242
  "aria-expanded": isOpen ? "true" : "false",
6705
8243
  title: isOpen ? "Hide indexing steps" : "Show indexing steps",
6706
8244
  onclick: function() {
6707
- toggleIndexGroup(group.runKey);
8245
+ toggleIndexGroup(group.key);
6708
8246
  },
6709
8247
  onkeydown: function(e) {
6710
8248
  if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
6711
8249
  e.preventDefault();
6712
- toggleIndexGroup(group.runKey);
8250
+ toggleIndexGroup(group.key);
6713
8251
  }
6714
8252
  },
6715
8253
  h("span", { class: "bq-index-icon", html: indexGroupIcon(group) }),
@@ -6728,7 +8266,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6728
8266
  if (isOpen && group.mayHaveOlder) {
6729
8267
  el.appendChild(h("div", {
6730
8268
  class: "bq-index-note",
6731
- text: "Earlier passes of this file are further back in the conversation. Scroll up to load them."
8269
+ text: "Earlier passes of this file are further back in the conversation. " + (group.resolvingReason === "history" ? "Loading them now." : "Scroll up to load them.")
6732
8270
  }));
6733
8271
  }
6734
8272
  return el;
@@ -6792,6 +8330,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6792
8330
  box.scrollTop = anchor.scrollTop;
6793
8331
  }
6794
8332
  function renderMessages() {
8333
+ syncStopIndexModal();
6795
8334
  if (!CS.messagesBox) return;
6796
8335
  if (CS.chatSettingsOpen) return;
6797
8336
  var anchor = captureScrollAnchor();
@@ -6815,22 +8354,28 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6815
8354
  CS.messagesBox.appendChild(greet);
6816
8355
  return;
6817
8356
  }
6818
- var rows = buildChatDisplayList(CS.messages, { hasMoreHistory: !CS.historyEndOfList });
8357
+ var rows = buildChatDisplayList(CS.messages, displayListOptions());
6819
8358
  rows.forEach(function(row) {
6820
8359
  if (row.kind === "indexing") {
6821
- var isOpen = !!CS.indexGroupsOpen[row.group.runKey];
8360
+ var isOpen = !!CS.indexGroupsOpen[row.group.key];
6822
8361
  var groupEl = buildIndexGroupEl(row.group, isOpen);
6823
8362
  groupEl.setAttribute("data-row-key", "g" + row.group.runKey);
6824
8363
  groupEl.setAttribute("data-row-pos", indexGroupAnchorId(row.group));
6825
8364
  CS.messagesBox.appendChild(groupEl);
6826
8365
  if (!isOpen) return;
6827
- row.group.members.forEach(function(member) {
8366
+ row.group.visibleMembers.forEach(function(member) {
6828
8367
  var pass = buildMessageEl(member.msg, member.index);
6829
8368
  pass.classList.add("bq-index-pass");
6830
8369
  pass.setAttribute("data-row-key", rowAnchorKey(member.msg, member.index));
6831
8370
  CS.messageEls[member.index] = pass;
6832
8371
  CS.messagesBox.appendChild(pass);
6833
8372
  });
8373
+ if (!row.group.finished && !row.group.resolving && row.group.status !== "cancelled") {
8374
+ CS.messagesBox.appendChild(h("div", {
8375
+ class: "bq-index-pass bq-index-tail",
8376
+ "aria-hidden": "true"
8377
+ }, h("span", { class: "bq-loader" })));
8378
+ }
6834
8379
  return;
6835
8380
  }
6836
8381
  var el = buildMessageEl(row.msg, row.index);
@@ -6839,6 +8384,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6839
8384
  CS.messagesBox.appendChild(el);
6840
8385
  });
6841
8386
  restoreScrollAnchor(anchor);
8387
+ hydrateMessageImagePreviews();
6842
8388
  }
6843
8389
  function refreshMessageBubble(idx) {
6844
8390
  if (idx < 0 || idx >= CS.messages.length) return;
@@ -6848,8 +8394,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6848
8394
  if (oldEl.classList.contains("bq-index-pass")) newEl.classList.add("bq-index-pass");
6849
8395
  oldEl.parentNode.replaceChild(newEl, oldEl);
6850
8396
  CS.messageEls[idx] = newEl;
8397
+ hydrateMessageImagePreviews();
6851
8398
  }
6852
8399
  function renderChat() {
8400
+ clearImagePreviewCache(S.projectId || "default");
8401
+ for (var uk in unavailableLinkMap) delete unavailableLinkMap[uk];
6853
8402
  CS.messages = [];
6854
8403
  CS.messageEls = [];
6855
8404
  CS.indexGroupsOpen = {};
@@ -7122,7 +8671,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
7122
8671
  var parsed = parseAiAgentValue2(raw);
7123
8672
  S.aiPlatform = parsed.platform;
7124
8673
  S.aiModel = parsed.model;
7125
- setProjectContextWindow(S.serviceId, parseAiAgentValue(raw).contextWindow);
8674
+ setProjectContextWindow(S.projectId, parseAiAgentValue(raw).contextWindow);
7126
8675
  S.serviceName = conn.service_name || "";
7127
8676
  S.serviceDescription = conn.service_description || "";
7128
8677
  }