feat(app): open local file references at the referenced line

Mark absolute Markdown links and inline-code relative paths as file
references, resolve them against the originating message cwd, and open
them in the configured editor. Local links no longer navigate the SPA
away from the running renderer.

The canonical transcript assembly projected messages down to seven
fields, so cwd and session_id never reached the renderer. Both are added
to SessionDetailMessage: cwd because the working directory can change
mid-session, session_id because it scopes which roots a reference may
resolve inside. The main process derives those roots from the database
rather than trusting anything the renderer sends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tommy0103
2026-07-31 05:03:21 +08:00
co-authored by Claude Opus 5
parent 87ad2892b6
commit c3751f0f6b
17 changed files with 730 additions and 21 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
@@ -6,6 +6,7 @@ import router from './router.js';
import { commitInitialData, fetchInitialData } from './data.js';
import { noteSessionUpdated, sessionLiveState } from './session-live.mjs';
import { createGlobalDataRefreshCoordinator } from './session-global-refresh.mjs';
import { installFileReferenceHandler } from './file-references.mjs';
// Import shared renderer CSS globally
import '../styles/base.css';
@@ -59,4 +60,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
@@ -2,6 +2,7 @@
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads store).
import { state } from './store.js';
import { markFileReferences, mayContainFileReference } from './file-references.mjs';
// --- Time / formatting ---
@@ -102,6 +103,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
@@ -1342,3 +1342,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); }