0 directories, 46 files

util

Home / tinai / util
/**
 * 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