0 directories, 46 files

util

Home / tinai / util
import ScratchpadModel from '../scratchpad-model.js';
import { scroller_of } from './scrollbars.js';

/** @typedef {import('../app.js').default} App */

let flash_el = null;
let flash_timer = null;

/**
 * Returns the scratchpad revision a request operates on: the pending revision
 * while a request runs, otherwise the latest revision.
 * @param {App} app - The main application instance.
 * @param {string} guid - The scratchpad conversation guid.
 * @returns {{conversation: Object, history: Array, revision: Object, index: number}|null} The revision context, or null when absent.
 */
export function get_scratchpad_revision(app, guid) {
	if (!guid) return null;
	const conversation = app.storage.get_conversation(guid);
	const history = conversation?.[app.storage.KEY_CONVERSATION_HISTORY];
	if (!Array.isArray(history) || history.length === 0) return null;
	const index = history.length - 1;
	const revision = history[index];
	if (!revision || typeof revision !== 'object') return null;
	return { conversation, history, revision, index };
}

/**
 * Applies an edit_scratchpad edit to the guid's scratchpad revision: mutates
 * the revision's items and table of contents (a removed item drops the toc
 * entries pointing at it), saves, and refreshes the live view.
 * @param {App} app - The main application instance.
 * @param {string} guid - The scratchpad conversation guid.
 * @param {Object} edit - The normalized edit payload.
 * @returns {{result: Object, state: {items: Array, toc: Array}}|null} The apply result and after-state, or null when the edit cannot apply.
 */
export function apply_scratchpad_edit(app, guid, edit) {
	const ctx = get_scratchpad_revision(app, guid);
	if (!ctx) return null;
	const model = new ScratchpadModel(Array.isArray(ctx.revision.items) ? ctx.revision.items : [], Array.isArray(ctx.revision.toc) ? ctx.revision.toc : []);
	const result = model.apply_edit(edit);
	if (!result) return null;
	const state = model.get_state();
	ctx.revision.items = state.items;
	ctx.revision.toc = state.toc;
	app.storage.save_conversation(guid, ctx.conversation);
	void refresh_scratchpad_view(app, guid, result.flashGuid);
	return { result, state };
}

/**
 * Applies an edit_scratchpad_toc edit to the guid's scratchpad revision,
 * saves, and refreshes the live view. The drawer opens when the first
 * entries arrive while it is closed.
 * @param {App} app - The main application instance.
 * @param {string} guid - The scratchpad conversation guid.
 * @param {Object} edit - The normalized edit payload.
 * @returns {{result: Object, state: {items: Array, toc: Array}}|null} The apply result and after-state, or null when the edit cannot apply.
 */
export function apply_scratchpad_toc_edit(app, guid, edit) {
	const ctx = get_scratchpad_revision(app, guid);
	if (!ctx) return null;
	const model = new ScratchpadModel(Array.isArray(ctx.revision.items) ? ctx.revision.items : [], Array.isArray(ctx.revision.toc) ? ctx.revision.toc : []);
	const had_entries = model.toc.length > 0;
	const result = model.apply_toc_edit(edit);
	if (!result) return null;
	const state = model.get_state();
	ctx.revision.items = state.items;
	ctx.revision.toc = state.toc;
	app.storage.save_conversation(guid, ctx.conversation);
	if (!had_entries && state.toc.length > 0 && app.scratchpad_drawer && !app.scratchpad_drawer.is_open()) {
		app.scratchpad_drawer.set_open(true);
	}
	void refresh_scratchpad_view(app, guid, result.flashGuid);
	return { result, state };
}

/**
 * Re-renders the scratchpad view when the guid is the displayed conversation
 * (the render refreshes the table of contents drawer) and flashes the
 * affected item. Background scratchpads only refresh their index entry.
 * @param {App} app - The main application instance.
 * @param {string} guid - The scratchpad conversation guid.
 * @param {string|null} [flashGuid=null] - The item guid to flash after the render.
 */
export async function refresh_scratchpad_view(app, guid, flashGuid = null) {
	const selected = app.storage.get_app_config()[app.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
	if (guid !== selected) {
		app.scratchpad_index?.on_conversation_index_updated?.();
		return;
	}
	await app.chat_renderer.render_scratchpad_conversation(false);
	if (flashGuid) {
		flash_scratchpad_item(app, flashGuid);
	}
}

/**
 * Rebuilds the table of contents drawer content for a scratchpad.
 * @param {App} app - The main application instance.
 * @param {string} guid - The scratchpad conversation guid.
 */
export function refresh_scratchpad_drawer(app, guid) {
	if (!app.scratchpad_drawer) return;
	const ctx = get_scratchpad_revision(app, guid);
	app.scratchpad_drawer.set_source(guid);
	render_scratchpad_toc(app, ctx?.revision?.items || [], ctx?.revision?.toc || []);
}

/**
 * Renders the scratchpad table of contents into the side drawer, sorted by
 * the position of each entry's target item. Entries show their title with
 * the optional short description beneath, and click scrolls to the target
 * item with the flash-fade; entries whose target no longer exists render
 * dimmed at the end.
 * @param {App} app - The main application instance.
 * @param {Array} items - The scratchpad items.
 * @param {Array} toc - The table of contents entries.
 */
export function render_scratchpad_toc(app, items, toc) {
	if (!app.scratchpad_drawer) return;
	const entries = ScratchpadModel.sort_toc(toc, items);
	if (entries.length === 0) {
		app.scratchpad_drawer.clear({ close_if_empty: true });
		return;
	}

	const panel = document.createElement('div');
	panel.className = 'div-scratchpad-toc';

	const title = document.createElement('div');
	title.className = 'div-scratchpad-toc-title';
	title.textContent = 'Table of Contents';
	panel.appendChild(title);

	const position = new Map((Array.isArray(items) ? items : []).map((item, index) => [item?.guid, index]));
	for (const entry of entries) {
		const target_index = position.get(entry?.target_guid);
		const entry_el = document.createElement('button');
		entry_el.type = 'button';
		entry_el.className = 'div-scratchpad-toc-entry' + (target_index === undefined ? ' toc-entry-orphan' : '');

		const title_el = document.createElement('span');
		title_el.className = 'span-scratchpad-toc-title';
		title_el.textContent = entry.title || 'Untitled';
		entry_el.appendChild(title_el);

		if (entry.description) {
			const description_el = document.createElement('span');
			description_el.className = 'span-scratchpad-toc-description';
			description_el.textContent = entry.description;
			entry_el.appendChild(description_el);
		}

		entry_el.onclick = () => {
			if (target_index === undefined) return;
			app.scratchpad_drawer.set_open(true);
			flash_scratchpad_item(app, entry.target_guid);
		};
		panel.appendChild(entry_el);
	}

	app.scratchpad_drawer.set_content(panel);
}

/**
 * Highlights a scratchpad item by guid and scrolls it into view, used as
 * feedback for tool edits and table of contents clicks. The highlight fades
 * out over three seconds through the scratchpad-item-flash animation.
 * @param {App} app - The main application instance.
 * @param {string} guid - The item guid.
 */
export function flash_scratchpad_item(app, guid) {
	clear_scratchpad_flash();
	if (!guid) return;
	const el = app.div_response?.querySelector(`[data-guid="${guid}"]`) || null;
	if (!el) return;
	flash_el = el;
	// restart the animation when the same item flashes again
	void el.offsetWidth;
	el.classList.add('scratchpad-item-flash');
	scroll_item_into_view(el);
	flash_timer = setTimeout(() => clear_scratchpad_flash(), 3100);
}

/**
 * Removes the flash highlight from the previously flashed item.
 */
export function clear_scratchpad_flash() {
	if (flash_timer) {
		clearTimeout(flash_timer);
		flash_timer = null;
	}
	if (flash_el) {
		flash_el.classList.remove('scratchpad-item-flash');
		flash_el = null;
	}
}

/**
 * Scrolls the chat container so an item is fully visible: an item coming in
 * from below is anchored at its bottom edge, one coming from above at its
 * top edge, and an item taller than the view is anchored at its bottom edge.
 * A fully visible item does not scroll. The jump is instant so rapid edit
 * batches cannot interrupt a smooth animation halfway.
 * @param {HTMLElement} el - The item element.
 */
function scroll_item_into_view(el) {
	const scroll = scroller_of(document.getElementById('id-chat-container-scroll'));
	if (!scroll) {
		el.scrollIntoView({ block: 'end' });
		return;
	}
	const view = scroll.getBoundingClientRect();
	const rect = el.getBoundingClientRect();
	let top = null;
	if (rect.height > view.height || rect.bottom > view.bottom) {
		top = rect.bottom - view.bottom + scroll.scrollTop;
	} else if (rect.top < view.top) {
		top = rect.top - view.top + scroll.scrollTop;
	}
	if (top === null) return;
	scroll.scrollTo({ top, behavior: 'auto' });
}
🌐
scratchpad-editor.js ×
Type: Web, text/x-java
8.64 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File