-
🌐 async-utils.jsWeb, 3.69 kB⬇
-
🌐 auth-storage.jsWeb, 735 B⬇
-
🌐 chat-helpers.jsWeb, 8.85 kB⬇
-
🌐 chat-renderer.jsWeb, 70.00 kB⬇
-
🌐 code-overlay.jsWeb, 21.77 kB⬇
-
🌐 confirm-dialog.jsWeb, 3.17 kB⬇
-
🌐 conversation-format-utils.jsWeb, 21.51 kB⬇
-
🌐 diagram-overlay.jsWeb, 7.93 kB⬇
-
🌐 dialog.jsWeb, 2.50 kB⬇
-
🌐 dom-utils.jsWeb, 8.33 kB⬇
-
🌐 drag-reorder.jsWeb, 4.82 kB⬇
-
🌐 elements.jsWeb, 11.06 kB⬇
-
🌐 export-utils.jsWeb, 1.53 kB⬇
-
🌐 format-utils.jsWeb, 29.60 kB⬇
-
🌐 history-navigation.jsWeb, 1.95 kB⬇
-
🌐 hljs-yaml.jsWeb, 5.07 kB⬇
-
🌐 inline-format.jsWeb, 7.35 kB⬇
-
🌐 instant-answer.jsWeb, 19.82 kB⬇
-
🌐 interactive-tools.jsWeb, 38.77 kB⬇
-
🌐 mermaid-fixes.jsWeb, 4.60 kB⬇
-
🌐 mermaid-render.jsWeb, 5.10 kB⬇
-
🌐 mermaid.jsWeb, 195 B⬇
-
🌐 migrations.jsWeb, 12.11 kB⬇
-
🌐 multi-choice-dialog.jsWeb, 1.33 kB⬇
-
🌐 notification-stack.jsWeb, 2.50 kB⬇
-
🌐 options-manager.jsWeb, 45.56 kB⬇
-
🌐 overlay-utils.jsWeb, 4.14 kB⬇
-
🌐 pdf-export.jsWeb, 23.59 kB⬇
-
🌐 pinch-zoom.jsWeb, 3.21 kB⬇
-
🌐 plugin-dialog.jsWeb, 2.17 kB⬇
-
🌐 read-aloud.jsWeb, 33.84 kB⬇
-
🌐 response-pipeline.jsWeb, 55.19 kB⬇
-
🌐 scratchpad-editor.jsWeb, 8.64 kB⬇
-
🌐 scratchpad-export.jsWeb, 3.56 kB⬇
-
🌐 scrollbars.jsWeb, 3.12 kB⬇
-
🌐 selection-manager.jsWeb, 3.76 kB⬇
-
🌐 settings-utils.jsWeb, 31.08 kB⬇
-
🌐 share.jsWeb, 10.75 kB⬇
-
🌐 side-drawer.jsWeb, 9.37 kB⬇
-
🌐 speech-text.jsWeb, 6.60 kB⬇
-
🌐 swipe-navigation.jsWeb, 6.16 kB⬇
-
🌐 table-overlay.jsWeb, 8.10 kB⬇
-
🌐 theme-utils.jsWeb, 8.34 kB⬇
-
🌐 thinking-panel-base.jsWeb, 14.88 kB⬇
-
🌐 ui-sets.jsWeb, 1.00 kB⬇
-
🌐 version-overlay.jsWeb, 4.63 kB⬇
/**
* Class ThinkingPanelBase
* Abstract base for the activity panel beside an editing surface: the
* streaming thinking log with its findings cards, output and costs blocks,
* the finding navigation, and the shared slide-in panel chrome
* (util/side-drawer.js). The log content is cached per conversation guid in
* memory and persisted through the app's storage, so it survives reloads.
*
* The panel also owns the shared request run lifecycle every editor repeats
* (stream_line, finish_run, fail_run), so editors only supply their endpoint
* payload and post-response extras. It is a view only: highlighting an edited
* item is delegated to the host editor through the on_highlight callback, and
* replaying interactive requests through the replay callback. Subclasses
* supply the DOM element ids of the drawer they are bound to.
*/
import { getEl } from './dom-utils.js';
import { build_thinking_block, escapeHtml, formatThinkingLineHtml, finding_card_html } from './format-utils.js';
import { cost_block_html, formatCostSummary } from './conversation-format-utils.js';
import { content_of, scroller_of, append_following_bottom } from './scrollbars.js';
import { handleInteractiveRequest } from './interactive-tools.js';
import SideDrawer from './side-drawer.js';
/**
* Class ThinkingPanelBase
* Instantiate once per editing surface with the surface's element ids (the
* document editor relies on the defaults; the choices and notes editors pass
* theirs inline) and the { on_highlight, replay } callbacks;
* give it the app instance through set_app so persistence works. The class
* doc at the top of this file describes the full contract.
*/
class ThinkingPanelBase {
#callbacks;
#els;
#app = null;
#guid = '';
#store = new Map();
#findings = [];
#selected_finding = -1;
#drawer = new SideDrawer();
/**
* Initializes the panel with its editor callbacks and DOM element ids.
* @param {object} callbacks - { on_highlight, replay }.
* @param {object} [elements] - The panel's element ids: { drawer_selector,
* content_id, clear_id, prev_id, next_id }. Subclasses override these;
* the defaults target the document editor's panel.
*/
constructor(callbacks, elements = {}) {
this.#callbacks = callbacks || {};
this.#els = {
drawer_selector: '.div-document-thinking',
content_id: 'div-document-thinking-content',
clear_id: 'btn-document-thinking-clear',
prev_id: 'btn-document-thinking-prev',
next_id: 'btn-document-thinking-next',
...elements
};
}
/**
* Supplies the application instance used for persisting the panel log.
* The editors receive it with the active conversation and forward it here.
* @param {App|null} app - The application instance.
*/
set_app(app) {
if (app) this.#app = app;
}
/**
* Binds the panel chrome through the shared side drawer and the finding
* navigation buttons.
* @param {HTMLElement} container - The container holding the panel drawer.
*/
bind(container) {
this.#drawer.bind(container.querySelector(this.#els.drawer_selector), null, { open: 'Close Thinking', close: 'Open Thinking' });
const clear_btn = getEl(this.#els.clear_id);
if (clear_btn) {
clear_btn.addEventListener('click', () => this.clear_findings());
}
const prev_btn = getEl(this.#els.prev_id);
if (prev_btn) {
prev_btn.addEventListener('click', () => {
if (this.#findings.length > 0) {
this.select_finding((this.#selected_finding - 1 + this.#findings.length) % this.#findings.length);
}
});
}
const next_btn = getEl(this.#els.next_id);
if (next_btn) {
next_btn.addEventListener('click', () => {
if (this.#findings.length > 0) {
this.select_finding((this.#selected_finding + 1) % this.#findings.length);
}
});
}
}
/**
* Switches the panel to the given conversation: the displayed content is
* stashed in the in-memory cache under its guid, and the target
* conversation's content is restored from the cache or the saved state,
* with unanswered interactive requests re-attached.
* @param {string} guid - The conversation guid to display.
* @returns {boolean} Whether the panel handled the switch.
*/
set_active_guid(guid) {
const content = this.#content_el();
if (!content) return false;
if (this.#guid === guid) return false;
if (this.#guid) {
this.#store.set(this.#guid, content.innerHTML);
}
this.#guid = guid;
this.#callbacks.on_highlight?.(null);
const stored = this.#store.get(guid);
if (stored !== undefined) {
content.innerHTML = stored;
} else {
const saved = this.#load_thinking(guid);
if (saved) {
content.innerHTML = saved;
this.#store.set(guid, saved);
} else {
content.innerHTML = '';
}
}
this.#register_findings();
this.#callbacks.replay?.(guid);
if (content.children.length === 0) {
// nothing to show: an empty panel must not stay open or pinned
this.#drawer.set_open(false);
this.#drawer.set_pinned(false);
}
this.#drawer.update_availability();
return true;
}
/**
* Starts a run: slides the panel open and inserts the streaming
* "Thinking" block for the run's conversation, into the live panel when
* displayed or the cached log otherwise.
* @param {string} guid - The conversation guid the run belongs to.
*/
begin_run(guid) {
this.#drawer.set_open(true);
const block = build_thinking_block('Thinking');
if (guid === this.#guid) {
this.#content_el()?.insertAdjacentHTML('beforeend', block);
// a starting run anchors the view to the bottom so the incoming
// thinking is visible from wherever the log was left
const scroller = scroller_of(getEl(this.#els.content_id));
scroller.scrollTop = scroller.scrollHeight;
} else {
this.#store.set(guid, (this.#store.get(guid) || '') + block);
}
this.#drawer.update_availability();
}
/**
* Appends html to the run's log: to the live panel when the conversation
* is displayed, otherwise to the conversation's cached content. Streamed
* thinking lines go inside the streaming thoughts block so it collapses
* with its content.
* @param {string} guid - The conversation guid the content belongs to.
* @param {string} html - The html to append.
* @param {boolean} [stream=false] - Whether the html is a streamed thinking line.
*/
append_html(guid, html, stream = false) {
if (!html) return;
const content = this.#content_el();
const live = guid === this.#guid && content;
if (live) {
// the view follows the stream only while it rests at the bottom: a
// user scroll-up breaks the anchor, scrolling back down re-engages it
const target = stream
? (content.querySelector('.instant-answer-thoughts:last-of-type .instant-answer-thoughts-content') || content)
: content;
append_following_bottom(scroller_of(getEl(this.#els.content_id)), () => target.insertAdjacentHTML('beforeend', html));
} else {
const stored = this.#store.get(guid);
if (stored === undefined) return;
if (stream) {
// while lines stream, the streaming thoughts block closes the string
const at = stored.lastIndexOf('</div></details>');
if (at !== -1) {
this.#store.set(guid, stored.slice(0, at) + html + stored.slice(at));
return;
}
}
this.#store.set(guid, stored + html);
}
}
/**
* Appends one streamed thinking line to the run's log.
* @param {string} guid - The conversation guid the run belongs to.
* @param {string} html - The formatted thinking line html.
*/
append_line(guid, html) {
this.append_html(guid, html, true);
}
/**
* Adds structured finding cards for a finished run, registering them for
* navigation when the conversation is displayed.
* @param {string} guid - The conversation guid the run belongs to.
* @param {Array} items - The structured findings { guid, type, title, analysis }.
*/
add_findings(guid, items) {
this.append_html(guid, (Array.isArray(items) ? items : [])
.filter(item => item && typeof item === 'object')
.map((item, i) => finding_card_html(item, i === 0))
.join(''));
if (guid === this.#guid) {
this.#register_findings();
}
}
/**
* Appends the final text output block for a finished run.
* @param {string} guid - The conversation guid the run belongs to.
* @param {string} text - The output text.
*/
set_output(guid, text) {
this.append_html(guid, '<div class="div-document-thinking-output">' + escapeHtml(text) + '</div>');
}
/**
* Ends a run: closes the streaming "Thinking" block again, in the live
* panel when displayed or in the cached log otherwise.
* @param {string} guid - The conversation guid the run belongs to.
*/
end_run(guid) {
if (guid === this.#guid) {
const content = this.#content_el();
const details = content?.querySelector('.instant-answer-thoughts:last-of-type');
details?.querySelector('.instant-answer-thoughts-content')?.classList.remove('streaming');
details?.removeAttribute('open');
return;
}
const stored = this.#store.get(guid);
if (stored === undefined) return;
let updated = stored.replace('instant-answer-thoughts-content streaming', 'instant-answer-thoughts-content');
updated = updated.replace('<details open class="instant-answer-thoughts">', '<details class="instant-answer-thoughts">');
this.#store.set(guid, updated);
}
/**
* Persists the run's log content for the conversation through the app's
* storage.
* @param {string} guid - The conversation guid the run belongs to.
*/
persist(guid) {
const html = guid === this.#guid
? this.#content_el()?.innerHTML
: this.#store.get(guid);
if (html !== undefined) {
this.#save_thinking(guid, html);
}
}
/**
* Handles one streamed thinking line for the current run: processes
* interactive tool requests through the app when available and appends
* the formatted line to the panel view or the cached log. Shared by every
* editor's streaming request.
* @param {string} guid - The conversation guid the run belongs to.
* @param {string} text - The raw streamed line.
* @param {App|null} [app=null] - The application instance, for interactive request handling.
*/
stream_line(guid, text, app = null) {
if (!text) return;
if (app) {
handleInteractiveRequest(text, guid, null, app);
}
this.append_line(guid, formatThinkingLineHtml(text));
}
/**
* Finishes a run with the endpoint's response: ends the streaming block,
* adds the structured findings or the text output plus the cost summary,
* and persists the log. Shared by every editor's streaming request; editors
* add their own post-response steps (title updates, mode restores) around
* it.
* @param {string} guid - The conversation guid the run belongs to.
* @param {Object|null} response - The endpoint response.
* @param {string} [fallback_output='No findings returned.'] - Output used when the response carries neither findings nor text.
* @returns {boolean} False when the response was an error (the output carries the error message).
*/
finish_run(guid, response, fallback_output = 'No findings returned.') {
this.end_run(guid);
if (response && response.error) {
this.set_output(guid, 'ERROR: ' + response.error);
return false;
}
const items = Array.isArray(response?.items) ? response.items : [];
if (items.length > 0) {
this.add_findings(guid, items);
} else {
const output = (response && (response.explanation || response.value)) || fallback_output;
this.set_output(guid, String(output));
}
this.append_html(guid, cost_block_html(formatCostSummary(response)));
this.persist(guid);
return true;
}
/**
* Finishes a run after a transport failure: ends the streaming block,
* adds the error output, and persists the log.
* @param {string} guid - The conversation guid the run belongs to.
* @param {Object|null} error - The transport error.
* @param {string} [fallback='Request failed.'] - Message used when the error carries no text.
*/
fail_run(guid, error, fallback = 'Request failed.') {
this.end_run(guid);
this.set_output(guid, 'ERROR: ' + (error?.error || error?.message || fallback));
this.persist(guid);
}
/**
* Selects a finding: highlights its card, highlights the referenced item
* by guid through the on_highlight callback, and scrolls it into view.
* @param {number} index - The finding position in the findings list.
*/
select_finding(index) {
if (index < 0 || index >= this.#findings.length) return;
if (this.#selected_finding >= 0 && this.#findings[this.#selected_finding]) {
this.#findings[this.#selected_finding].el.classList.remove('selected');
}
this.#callbacks.on_highlight?.(null);
this.#selected_finding = index;
const finding = this.#findings[index];
finding.el.classList.add('selected');
if (finding.guid) {
this.#callbacks.on_highlight?.(finding.guid);
}
}
/**
* Clears the current finding selection: removes the selected card's
* highlight and the referenced item's highlight. The panel contents are
* left untouched.
*/
clear_findings() {
if (this.#selected_finding >= 0 && this.#findings[this.#selected_finding]) {
this.#findings[this.#selected_finding].el.classList.remove('selected');
}
this.#selected_finding = -1;
this.#callbacks.on_highlight?.(null);
}
/**
* Shows or hides the whole drawer for the current surface, independent
* of its open/pinned state.
* @param {boolean} visible - Whether the drawer is available on the current surface.
*/
set_visible(visible) {
this.#drawer.set_visible(visible);
}
/**
* Returns the panel's log content element.
* @returns {HTMLElement|null} The content element.
*/
#content_el() {
return content_of(getEl(this.#els.content_id));
}
/**
* Loads the saved thinking log content for a conversation from the app's
* storage.
* @param {string} guid - The conversation guid.
* @returns {string|null} The saved panel html, or null when absent.
*/
#load_thinking(guid) {
if (!this.#app || !guid) return null;
return this.#app.storage.get_conversation(guid)?.[this.#app.storage.KEY_CONVERSATION_THINKING] || null;
}
/**
* Persists the thinking log content for a conversation to the app's
* storage.
* @param {string} guid - The conversation guid.
* @param {string} html - The panel html.
*/
#save_thinking(guid, html) {
if (!this.#app || !guid) return;
this.#app.storage.update_conversation_field(guid, this.#app.storage.KEY_CONVERSATION_THINKING, html);
}
/**
* Registers all finding cards currently in the panel for navigation,
* selecting the last selected-marked card if present.
*/
#register_findings() {
const content = this.#content_el();
if (!content) return;
this.#findings = Array.from(content.querySelectorAll('.code-analysis-item')).map(el => {
const finding = { el, guid: el.dataset.guid || '' };
el.onclick = () => this.select_finding(this.#findings.indexOf(finding));
return finding;
});
const selected = Array.from(content.querySelectorAll('.code-analysis-item.selected'));
this.#selected_finding = selected.length > 0
? Array.from(content.querySelectorAll('.code-analysis-item')).indexOf(selected[selected.length - 1])
: -1;
}
}
export default ThinkingPanelBase;
thinking-panel-base.js
×
Type: Web, text/plain
14.88 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File
Type: Web, text/plain
14.88 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File