chore(renderer): merge main into image rendering fix

This commit is contained in:
KinomotoMio
2026-07-31 15:23:13 +08:00
18 changed files with 1668 additions and 2784 deletions
+112
View File
@@ -0,0 +1,112 @@
// Two reference shapes appear in transcripts and need different handling:
// A. Markdown links with an absolute path — `[roadmap.md](/Users/me/proj/roadmap.md:162)`
// B. Inline code holding a project-relative path — `` `src/hooks/hook.ts:40` ``
// Both end up as an anchor carrying the split-out components; the main process resolves them.
const REFERENCE_SUFFIX = /:(\d+)(?::(\d+)|-(\d+))?$/;
const INLINE_CODE_REFERENCE = /^[\w@.\-/]+\.[A-Za-z0-9]+:\d+(?::\d+|-\d+)?$/;
const LOCAL_HREF = /^(?:file:|\/|[A-Za-z]:[\\/])/;
export function isLocalHref(href) {
return typeof href === 'string' && LOCAL_HREF.test(href.trim());
}
// Deliberately strict: an extension *and* a line number are both required. Plain inline code
// such as `package.json` or `useState` must never become a link.
export function isInlineCodeReference(text) {
return typeof text === 'string' && INLINE_CODE_REFERENCE.test(text.trim());
}
export function parseFileReference(raw) {
if (typeof raw !== 'string' || !raw.trim()) return null;
const text = raw.trim();
const match = REFERENCE_SUFFIX.exec(text);
if (!match) return { path: text, line: null, column: null, endLine: null };
return {
path: text.slice(0, match.index),
line: Number(match[1]),
column: match[2] ? Number(match[2]) : null,
endLine: match[3] ? Number(match[3]) : null,
};
}
// Only `file:` URLs are percent-encoded. Decoding a plain path would corrupt any filename
// that legitimately contains `%`.
export function hrefToPath(href) {
if (!href.startsWith('file:')) return href;
try {
return decodeURIComponent(new URL(href).pathname);
} catch {
return '';
}
}
function applyReference(el, raw, { cwd, sessionId }) {
const ref = parseFileReference(raw);
if (!ref?.path) return false;
el.classList.add('file-ref');
el.dataset.filePath = ref.path;
if (cwd) el.dataset.fileCwd = cwd;
if (sessionId) el.dataset.fileSession = sessionId;
if (ref.line) el.dataset.fileLine = String(ref.line);
if (ref.column) el.dataset.fileColumn = String(ref.column);
if (ref.endLine) el.dataset.fileEndLine = String(ref.endLine);
return true;
}
function markLinkReferences(rootEl, context) {
for (const anchor of rootEl.querySelectorAll('a[href]')) {
const href = anchor.getAttribute('href') || '';
if (!isLocalHref(href)) continue;
const filePath = hrefToPath(href.trim());
if (filePath) applyReference(anchor, filePath, context);
}
}
// Fenced blocks render as <pre><code>; only inline code is linkified, so a path quoted inside
// a code sample never turns into navigation.
function markInlineCodeReferences(rootEl, context) {
if (!context.cwd) return;
for (const code of rootEl.querySelectorAll('code')) {
if (code.closest('pre')) continue;
const text = code.textContent.trim();
if (!isInlineCodeReference(text)) continue;
const anchor = document.createElement('a');
anchor.textContent = text;
if (!applyReference(anchor, text, context)) continue;
code.replaceChildren(anchor);
}
}
// Cheap pre-filter on the rendered HTML. Most messages contain neither a link nor inline code,
// and this runs for every rendered row — a string scan is far cheaper than walking the DOM.
export function mayContainFileReference(html) {
return typeof html === 'string' && (html.includes('<a ') || html.includes('<code'));
}
export function markFileReferences(rootEl, { cwd = null, sessionId = null } = {}) {
const context = { cwd, sessionId };
markLinkReferences(rootEl, context);
markInlineCodeReferences(rootEl, context);
return rootEl;
}
function onClick(event) {
const anchor = event.target instanceof Element ? event.target.closest('a.file-ref') : null;
if (!anchor) return;
event.preventDefault();
const { filePath, fileCwd, fileSession, fileLine, fileColumn } = anchor.dataset;
if (!filePath) return;
void window.obelisk?.openFileReference?.({
sessionId: fileSession || null,
path: filePath,
cwd: fileCwd || null,
line: fileLine ? Number(fileLine) : null,
column: fileColumn ? Number(fileColumn) : null,
});
}
export function installFileReferenceHandler(target = document) {
target.addEventListener('click', onClick);
return () => target.removeEventListener('click', onClick);
}
+3
View File
@@ -7,6 +7,7 @@ import { commitInitialData, fetchInitialData } from './data.js';
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
import { createGlobalDataRefreshCoordinator } from './session-global-refresh.mjs';
import { registerSessionImageElement } from './session-image-element.js';
import { installFileReferenceHandler } from './file-references.mjs';
// Import shared renderer CSS globally
import '../styles/base.css';
@@ -62,4 +63,6 @@ window.obelisk?.onSessionUpdated?.(({ sessionId } = {}) => {
noteSessionUpdated(sessionLiveState, sessionId, currentSessionId);
});
installFileReferenceHandler();
app.mount('#app');
@@ -252,6 +252,7 @@ export function buildSessionTimelinePresentation(item, { query = '', expandedTex
const toolArgPreviews = new Map();
const toolIcons = new Map();
const workflowAgentGroups = new Map();
const refs = { cwd: message.cwd || null, sessionId: message.session_id || null };
for (const toolCall of toolCalls) {
const input = parseToolInput(toolCall);
@@ -263,7 +264,7 @@ export function buildSessionTimelinePresentation(item, { query = '', expandedTex
toolPrettyHtml.set(toolCall.id, renderPrettyTool(toolCall));
}
if ((toolCall.name === 'Agent' || toolCall.name === 'Task') && toolCall.result?.content) {
toolResultHtml.set(toolCall.id, renderMarkdown(toolCall.result.content, { variant: 'compact' }));
toolResultHtml.set(toolCall.id, renderMarkdown(toolCall.result.content, { variant: 'compact', ...refs }));
}
if (toolCall.name === 'Workflow') workflowAgentGroups.set(toolCall.id, groupWorkflowAgents(toolCall.workflow));
}
@@ -271,14 +272,14 @@ export function buildSessionTimelinePresentation(item, { query = '', expandedTex
const effectiveText = expandedText ?? message.text;
return {
messageHtml: message.text
? renderMarkdown(effectiveText, { variant: item?.kind === 'meta' ? 'compact' : 'msg', query })
? renderMarkdown(effectiveText, { variant: item?.kind === 'meta' ? 'compact' : 'msg', query, ...refs })
: '',
thinkingHtml: message._thinking
? renderMarkdown(message._thinking, { variant: 'msg', query })
: (item?.kind === 'thinking' ? renderMarkdown(message.text, { variant: 'msg', query }) : ''),
skillHtml: message._skillMd ? renderMarkdown(message._skillMd, { variant: 'compact' }) : '',
? renderMarkdown(message._thinking, { variant: 'msg', query, ...refs })
: (item?.kind === 'thinking' ? renderMarkdown(message.text, { variant: 'msg', query, ...refs }) : ''),
skillHtml: message._skillMd ? renderMarkdown(message._skillMd, { variant: 'compact', ...refs }) : '',
summaryHtml: message.summary?.content
? renderMarkdown(message.summary.content, { variant: 'compact' })
? renderMarkdown(message.summary.content, { variant: 'compact', ...refs })
: '',
toolInputs,
toolInputText,
+6
View File
@@ -3,6 +3,7 @@
import { state } from './store.js';
import { configureMarkdownImages } from './markdown-image-renderer.js';
import { markFileReferences, mayContainFileReference } from './file-references.mjs';
// --- Time / formatting ---
@@ -104,6 +105,11 @@ export function renderMarkdown(text, opts = {}) {
const container = document.createElement('div');
container.className = cls;
container.innerHTML = html;
// Without a cwd or session to resolve against, a reference could never be opened — leaving it
// unmarked keeps it from looking actionable.
if ((opts.cwd || opts.sessionId) && mayContainFileReference(html)) {
markFileReferences(container, { cwd: opts.cwd, sessionId: opts.sessionId });
}
if (opts.query) highlightTextNodes(container, opts.query.trim());
return container.outerHTML;
}
+27
View File
@@ -7,6 +7,8 @@ const sources = ref([]);
const dbPath = ref('');
const recapPath = ref('');
const autoRefresh = ref(true);
const editorScheme = ref('vscode');
const editorSchemes = ['vscode', 'vscode-insiders', 'cursor', 'windsurf', 'zed'];
const memoryCount = ref(0);
const rebuilding = ref(false);
const version = ref('0.1.0');
@@ -22,9 +24,15 @@ async function loadSettings() {
dbPath.value = s.dbPath || '';
recapPath.value = s.recapDir || '~/.obelisk/recap';
autoRefresh.value = s.autoRefresh !== false;
editorScheme.value = s.editorScheme || 'vscode';
memoryCount.value = s.memoryCount || 0;
}
async function saveEditorScheme(value) {
editorScheme.value = value;
await saveSetting('editorScheme', value);
}
async function browseSourcePath(source) {
if (!window.obelisk?.browseFolder) return;
const result = await window.obelisk.browseFolder();
@@ -168,6 +176,25 @@ function fmtRelative(iso) {
</label>
</section>
<!-- Editor -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Editor</h2>
<p>Which editor opens file references found in session transcripts.</p>
</div>
<div class="form-row">
<div>
<div class="form-label">Editor URL scheme</div>
<div class="form-label-hint">Clicking <code>src/app.ts:42</code> jumps to that line.</div>
</div>
<div class="form-control">
<select class="form-input" :value="editorScheme" @change="saveEditorScheme($event.target.value)">
<option v-for="opt in editorSchemes" :key="opt" :value="opt">{{ opt }}</option>
</select>
</div>
</div>
</section>
<!-- Recap -->
<section class="settings-section">
<div class="settings-section-head">
@@ -37,6 +37,10 @@ async function handleLoadFull(uuid, el) {
el.remove();
}
}
function renderMsgMarkdown(msg, text, variant) {
return renderMarkdown(text, { variant, cwd: msg?.cwd || null, sessionId: msg?.session_id || null });
}
</script>
<template>
@@ -69,7 +73,7 @@ async function handleLoadFull(uuid, el) {
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="renderMarkdown(msg.text, { variant: 'msg' })"></div>
<div class="thinking-body" v-html="renderMsgMarkdown(msg, msg.text, 'msg')"></div>
</div>
</template>
@@ -81,7 +85,7 @@ async function handleLoadFull(uuid, el) {
<span class="meta-label">System</span>
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
</button>
<div class="meta-body" v-html="renderMarkdown(msg.text, { variant: 'compact' })"></div>
<div class="meta-body" v-html="renderMsgMarkdown(msg, msg.text, 'compact')"></div>
</div>
</template>
@@ -96,9 +100,9 @@ async function handleLoadFull(uuid, el) {
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
<span class="thinking-label">Thinking</span>
</button>
<div class="thinking-body" v-html="renderMarkdown(msg._thinking, { variant: 'msg' })"></div>
<div class="thinking-body" v-html="renderMsgMarkdown(msg, msg._thinking, 'msg')"></div>
</div>
<div v-if="msg.text" v-html="renderMarkdown(msg.text, { variant: 'msg' })"></div>
<div v-if="msg.text" v-html="renderMsgMarkdown(msg, msg.text, 'msg')"></div>
<div v-else-if="!msg.tool_calls?.length" class="msg-text empty-text">(no text content)</div>
<button
v-if="isTextTruncated(msg.text)"
+9
View File
@@ -1356,3 +1356,12 @@
font-variant-numeric: tabular-nums;
}
.msg-nav-pos .msg-nav-current { color: var(--fg-2); font-weight: 500; }
.file-ref {
color: var(--accent-2); cursor: pointer;
text-decoration: underline; text-decoration-color: var(--accent-soft);
text-underline-offset: 3px; transition: all 0.1s;
}
.file-ref:hover { color: var(--accent-2); text-decoration-color: var(--accent-2); }
code > .file-ref { color: inherit; text-decoration-color: var(--muted-2); }
code > .file-ref:hover { color: var(--accent-2); text-decoration-color: var(--accent-2); }