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:
co-authored by
Claude Opus 5
parent
87ad2892b6
commit
c3751f0f6b
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'file-reference': resolve('src/main/file-reference.ts'),
|
||||
indexer: resolve('src/main/indexer.ts'),
|
||||
'indexer-service': resolve('src/main/indexer-service.ts'),
|
||||
'indexer-worker': resolve('src/main/indexer-worker.ts'),
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@
|
||||
"verify:icons": "node scripts/verify-icons.mjs",
|
||||
"test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs",
|
||||
"test:electron:timeline": "electron-vite build && electron --no-sandbox tests/electron-session-virtualization.mjs",
|
||||
"test:electron:reader-state": "electron-vite build && electron --no-sandbox tests/electron-session-reader-state.mjs"
|
||||
"test:electron:reader-state": "electron-vite build && electron --no-sandbox tests/electron-session-reader-state.mjs",
|
||||
"test:electron:file-refs": "electron-vite build && electron --no-sandbox tests/electron-file-references.mjs"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.obelisk.app",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const EDITOR_SCHEMES: Record<string, string> = {
|
||||
vscode: 'vscode',
|
||||
'vscode-insiders': 'vscode-insiders',
|
||||
cursor: 'cursor',
|
||||
windsurf: 'windsurf',
|
||||
zed: 'zed',
|
||||
};
|
||||
|
||||
const DEFAULT_EDITOR_SCHEME = 'vscode';
|
||||
|
||||
interface FileReferenceQuery {
|
||||
rawPath?: string | null;
|
||||
cwd?: string | null;
|
||||
roots?: (string | null | undefined)[];
|
||||
}
|
||||
|
||||
interface EditorUrlOptions {
|
||||
scheme?: string | null;
|
||||
filePath: string;
|
||||
line?: number | null;
|
||||
column?: number | null;
|
||||
}
|
||||
|
||||
function normalizeRoots(roots: (string | null | undefined)[] = []): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const root of roots) {
|
||||
if (typeof root !== 'string' || !root.trim() || !path.isAbsolute(root)) continue;
|
||||
seen.add(path.resolve(root));
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
function isWithinRoots(candidate: string, roots: string[]): boolean {
|
||||
return roots.some((root) => candidate === root || candidate.startsWith(root + path.sep));
|
||||
}
|
||||
|
||||
// A reference may be absolute, relative to the message cwd, or project-relative with a
|
||||
// leading slash (`/src/foo.ts`). path.join treats the leading slash as a no-op, so the same
|
||||
// join covers the last two cases.
|
||||
function fileReferenceCandidates({ rawPath, cwd, roots = [] }: FileReferenceQuery): string[] {
|
||||
if (typeof rawPath !== 'string' || !rawPath.trim()) return [];
|
||||
const cleaned = rawPath.trim();
|
||||
const bases = normalizeRoots([cwd, ...roots]);
|
||||
const candidates: string[] = [];
|
||||
if (path.isAbsolute(cleaned)) candidates.push(path.resolve(cleaned));
|
||||
for (const base of bases) candidates.push(path.resolve(base, `.${path.sep}${cleaned}`));
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
// Resolves to an existing file that lives inside one of the session's own roots. Transcript
|
||||
// text is untrusted input, so containment is checked after realpath — a symlink pointing out
|
||||
// of the project must not widen what the app will open.
|
||||
function resolveFileReference(query: FileReferenceQuery): string | null {
|
||||
const roots = normalizeRoots([query.cwd, ...(query.roots || [])]);
|
||||
if (!roots.length) return null;
|
||||
for (const candidate of fileReferenceCandidates(query)) {
|
||||
if (!isWithinRoots(candidate, roots)) continue;
|
||||
try {
|
||||
const real = fs.realpathSync(candidate);
|
||||
if (!isWithinRoots(real, roots)) continue;
|
||||
if (fs.statSync(real).isFile()) return real;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildEditorUrl({ scheme, filePath, line, column }: EditorUrlOptions): string {
|
||||
const resolved = EDITOR_SCHEMES[String(scheme || '')] || DEFAULT_EDITOR_SCHEME;
|
||||
let target = `${resolved}://file${encodeURI(filePath)}`;
|
||||
const lineNumber = Number(line);
|
||||
if (Number.isInteger(lineNumber) && lineNumber > 0) {
|
||||
target += `:${lineNumber}`;
|
||||
const columnNumber = Number(column);
|
||||
if (Number.isInteger(columnNumber) && columnNumber > 0) target += `:${columnNumber}`;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_EDITOR_SCHEME,
|
||||
EDITOR_SCHEMES,
|
||||
buildEditorUrl,
|
||||
fileReferenceCandidates,
|
||||
isWithinRoots,
|
||||
normalizeRoots,
|
||||
resolveFileReference,
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { writeHeartbeat } from './indexer.ts';
|
||||
import { createIndexerService } from './indexer-service.ts';
|
||||
import { createWorkerBuildIndex } from './indexer-worker-client.ts';
|
||||
import { buildRecapExportQuery } from './recap-capture-query.ts';
|
||||
import { buildEditorUrl, DEFAULT_EDITOR_SCHEME, EDITOR_SCHEMES, resolveFileReference } from './file-reference.ts';
|
||||
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
|
||||
import { migrateCoreSchemaColumns } from '../../../packages/core/src/schema-migrations.ts';
|
||||
import { createBuiltinProviderRegistry } from '../../../packages/core/src/providers/builtins.ts';
|
||||
@@ -298,6 +299,30 @@ async function stopBackgroundResources({ stopWorker = false } = {}) {
|
||||
closeDb();
|
||||
}
|
||||
|
||||
function safeProtocol(url: string): string {
|
||||
try { return new URL(url).protocol; } catch { return ''; }
|
||||
}
|
||||
|
||||
function isSameOrigin(url: string, currentUrl: string): boolean {
|
||||
try {
|
||||
return new URL(url).origin === new URL(currentUrl).origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reloads and in-app routing keep the same origin and pathname; anything else is a real
|
||||
// navigation away from the renderer document.
|
||||
function isSameDocumentNavigation(url: string, currentUrl: string): boolean {
|
||||
try {
|
||||
const target = new URL(url);
|
||||
const current = new URL(currentUrl);
|
||||
return target.origin === current.origin && target.pathname === current.pathname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const isDev = process.argv.includes('--dev') || !!process.env.ELECTRON_RENDERER_URL;
|
||||
const shouldOpenDevTools = process.argv.includes('--devtools');
|
||||
@@ -325,6 +350,25 @@ function createWindow() {
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the SPA in place: a Markdown link in a transcript must never replace the running
|
||||
// renderer. External http(s) targets are handed to the system browser instead.
|
||||
// Only genuinely external targets go to the browser. A same-origin URL is a local document
|
||||
// reference — in dev that would hand the system browser a Vite-served source file.
|
||||
const releaseNavigation = (url: string) => {
|
||||
if (!/^https?:$/i.test(safeProtocol(url))) return;
|
||||
if (isSameOrigin(url, win.webContents.getURL())) return;
|
||||
shell.openExternal(url).catch(() => {});
|
||||
};
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
releaseNavigation(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
if (isSameDocumentNavigation(url, win.webContents.getURL())) return;
|
||||
event.preventDefault();
|
||||
releaseNavigation(url);
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
win.loadURL(process.env.ELECTRON_RENDERER_URL || process.env.OBELISK_DEV_SERVER_URL || 'http://localhost:5173');
|
||||
if (shouldOpenDevTools) {
|
||||
@@ -589,6 +633,39 @@ ipcMain.handle('db:readMemoryFile', (_, filePath) => {
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
// Every root this session actually worked in. A reference is only opened if it lands inside
|
||||
// one of them, so untrusted transcript text cannot reach a file outside the session's own
|
||||
// projects. Scoping is deliberately per session, not corpus-wide.
|
||||
function querySessionFileRoots(sessionId: unknown): string[] {
|
||||
if (!db || typeof sessionId !== 'string' || !sessionId) return [];
|
||||
const roots: string[] = [];
|
||||
try {
|
||||
const rows = db.prepare(
|
||||
`SELECT DISTINCT cwd FROM messages WHERE session_id = ? AND cwd IS NOT NULL AND cwd != ''`
|
||||
).all(sessionId);
|
||||
for (const row of rows) roots.push(row.cwd);
|
||||
const session = db.prepare(`SELECT project_path FROM sessions WHERE id = ?`).get(sessionId);
|
||||
if (session?.project_path) roots.push(session.project_path);
|
||||
} catch {}
|
||||
return roots;
|
||||
}
|
||||
|
||||
ipcMain.handle('file-ref:open', async (_, ref) => {
|
||||
const { sessionId, path: rawPath, cwd, line, column } = ref || {};
|
||||
const roots = querySessionFileRoots(sessionId);
|
||||
// The renderer-supplied cwd only counts if this session actually recorded it.
|
||||
const scopedCwd = typeof cwd === 'string' && roots.includes(cwd) ? cwd : null;
|
||||
const filePath = resolveFileReference({ rawPath, cwd: scopedCwd, roots });
|
||||
if (!filePath) return { opened: false };
|
||||
const { editorScheme } = loadPersistedSettings();
|
||||
try {
|
||||
await shell.openExternal(buildEditorUrl({ scheme: editorScheme, filePath, line, column }));
|
||||
return { opened: true, path: filePath };
|
||||
} catch {
|
||||
return { opened: false, path: filePath };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('db:archiveMemory', (_, id, reason) => {
|
||||
return runAppDbWrite(() => {
|
||||
db.prepare(`UPDATE memories SET deleted_at = ?, deleted_reason = ? WHERE id = ?`)
|
||||
@@ -814,6 +891,7 @@ ipcMain.handle('settings:get', () => {
|
||||
dbPath: dbFile,
|
||||
recapDir,
|
||||
autoRefresh: persisted.autoRefresh !== false,
|
||||
editorScheme: persisted.editorScheme || DEFAULT_EDITOR_SCHEME,
|
||||
sources,
|
||||
memoryCount,
|
||||
sessionCount,
|
||||
|
||||
@@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld('obelisk', {
|
||||
getMessageFullText: (uuid: string) => ipcRenderer.invoke('db:getMessageFullText', uuid),
|
||||
getMemories: () => ipcRenderer.invoke('db:getMemories'),
|
||||
readMemoryFile: (path: string) => ipcRenderer.invoke('db:readMemoryFile', path),
|
||||
openFileReference: (ref: { sessionId?: string | null; path: string; cwd?: string | null; line?: number | null; column?: number | null }) =>
|
||||
ipcRenderer.invoke('file-ref:open', ref),
|
||||
archiveMemory: (id: string, reason?: string) => ipcRenderer.invoke('db:archiveMemory', id, reason),
|
||||
restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id),
|
||||
getProjects: () => ipcRenderer.invoke('db:getProjects'),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Covers the DOM half of file-references.mjs, which the node --test suite cannot reach:
|
||||
// which references become links, which must be left alone, and what the click sends.
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { createSessionPatch } from '../src/shared/session-patch.mjs';
|
||||
import { assembleSessionDetail } from '../src/shared/session-detail-assembly.mjs';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = join(here, '..');
|
||||
const sessionId = 'file-ref-session';
|
||||
const cwd = '/tmp/obelisk-file-ref-fixture';
|
||||
const channels = [
|
||||
'db:getSessions',
|
||||
'db:getSessionMessages',
|
||||
'db:getSessionToolCalls',
|
||||
'db:getSessionToolResults',
|
||||
'db:getSessionPatch',
|
||||
'db:getSessionSubagents',
|
||||
'db:getSessionWorkflows',
|
||||
'db:getSessionSummaries',
|
||||
'db:getMessageFullText',
|
||||
'db:getMemories',
|
||||
'db:getProjects',
|
||||
'db:getStats',
|
||||
'settings:get',
|
||||
'file-ref:open',
|
||||
];
|
||||
|
||||
let failures = 0;
|
||||
const openCalls = [];
|
||||
|
||||
const messageText = [
|
||||
'Absolute link: [roadmap.md](/tmp/obelisk-file-ref-fixture/docs/roadmap.md:162)',
|
||||
'',
|
||||
'Inline relative: `src/app.ts:40`',
|
||||
'',
|
||||
'Plain inline code: `package.json` and `useState`',
|
||||
'',
|
||||
'Fenced block below must stay inert:',
|
||||
'',
|
||||
'```ts',
|
||||
'src/should-not-link.ts:99',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const messages = [{
|
||||
uuid: 'file-ref-message-0',
|
||||
session_id: sessionId,
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
timestamp: '2026-07-16T00:00:00.000Z',
|
||||
text: messageText,
|
||||
content_type: 'text',
|
||||
is_meta: 0,
|
||||
cwd,
|
||||
}];
|
||||
|
||||
function summary() {
|
||||
return {
|
||||
id: sessionId,
|
||||
title: 'File reference fixture',
|
||||
project: 'quiet-zero',
|
||||
project_path: cwd,
|
||||
source: 'codex',
|
||||
started_at: '2026-07-16T00:00:00.000Z',
|
||||
ended_at: '2026-07-16T01:00:00.000Z',
|
||||
message_count: messages.length,
|
||||
git_branch: 'main',
|
||||
};
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) console.log(`PASS: ${message}`);
|
||||
else {
|
||||
failures++;
|
||||
console.error(`FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(webContents, expression, message, timeoutMs = 8_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await webContents.executeJavaScript(`Boolean(${expression})`, true)) return;
|
||||
await delay(40);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${message}`);
|
||||
}
|
||||
|
||||
function registerHandlers() {
|
||||
ipcMain.handle('db:getSessions', () => [summary()]);
|
||||
ipcMain.handle('db:getSessionMessages', () => messages);
|
||||
ipcMain.handle('db:getSessionToolCalls', () => []);
|
||||
ipcMain.handle('db:getSessionToolResults', () => []);
|
||||
ipcMain.handle('db:getSessionPatch', (_event, id, cursor) => {
|
||||
const patch = createSessionPatch({
|
||||
messages: assembleSessionDetail({
|
||||
messages, toolCalls: [], toolResults: [], subagents: [], workflows: [],
|
||||
}).messages,
|
||||
workflows: [],
|
||||
}, cursor);
|
||||
return { ...patch, session: summary() };
|
||||
});
|
||||
ipcMain.handle('db:getSessionSubagents', () => []);
|
||||
ipcMain.handle('db:getSessionWorkflows', () => []);
|
||||
ipcMain.handle('db:getSessionSummaries', () => []);
|
||||
ipcMain.handle('db:getMessageFullText', () => null);
|
||||
ipcMain.handle('db:getMemories', () => []);
|
||||
ipcMain.handle('db:getProjects', () => [{ project: 'quiet-zero', count: 1 }]);
|
||||
ipcMain.handle('db:getStats', () => ({}));
|
||||
ipcMain.handle('settings:get', () => ({}));
|
||||
ipcMain.handle('file-ref:open', (_event, ref) => {
|
||||
openCalls.push(ref);
|
||||
return { opened: false };
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
registerHandlers();
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
preload: join(appRoot, 'out', 'preload', 'index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
await win.loadFile(join(appRoot, 'out', 'renderer', 'index.html'), { hash: '/sessions' });
|
||||
await waitFor(win.webContents, `document.body.textContent.includes('File reference fixture')`, 'session list');
|
||||
|
||||
await win.webContents.executeJavaScript(
|
||||
`window.location.hash = ${JSON.stringify(`#/sessions/${sessionId}`)}`, true,
|
||||
);
|
||||
await waitFor(win.webContents, `document.querySelectorAll('a.file-ref').length > 0`, 'marked references');
|
||||
await delay(200);
|
||||
|
||||
const marks = await win.webContents.executeJavaScript(`(() => {
|
||||
const refs = [...document.querySelectorAll('a.file-ref')].map(el => ({
|
||||
path: el.dataset.filePath,
|
||||
line: el.dataset.fileLine || null,
|
||||
cwd: el.dataset.fileCwd || null,
|
||||
session: el.dataset.fileSession || null,
|
||||
inCode: Boolean(el.closest('code')),
|
||||
inPre: Boolean(el.closest('pre')),
|
||||
}));
|
||||
const preText = [...document.querySelectorAll('pre')].map(p => p.textContent).join('\\n');
|
||||
return { refs, preText, preRefCount: document.querySelectorAll('pre .file-ref').length };
|
||||
})()`, true);
|
||||
|
||||
const link = marks.refs.find(r => r.path === '/tmp/obelisk-file-ref-fixture/docs/roadmap.md');
|
||||
assert(Boolean(link), `absolute markdown link becomes a reference (${JSON.stringify(marks.refs)})`);
|
||||
assert(link?.line === '162', 'absolute link keeps its line number');
|
||||
|
||||
const inline = marks.refs.find(r => r.path === 'src/app.ts');
|
||||
assert(Boolean(inline), 'inline code with a line number becomes a reference');
|
||||
assert(inline?.line === '40', 'inline reference keeps its line number');
|
||||
assert(inline?.cwd === cwd, 'inline reference carries the message cwd');
|
||||
assert(inline?.session === sessionId, 'reference carries the session id');
|
||||
assert(inline?.inCode === true, 'inline reference stays inside its <code> element');
|
||||
|
||||
assert(marks.preRefCount === 0, 'no reference is created inside a fenced block');
|
||||
assert(
|
||||
marks.preText.includes('src/should-not-link.ts:99'),
|
||||
'fenced block still shows its original path text',
|
||||
);
|
||||
assert(
|
||||
marks.refs.every(r => r.path !== 'package.json' && r.path !== 'useState'),
|
||||
'ordinary inline code is not turned into a reference',
|
||||
);
|
||||
assert(marks.refs.length === 2, `exactly two references are marked (got ${marks.refs.length})`);
|
||||
|
||||
const navigatedAway = [];
|
||||
win.webContents.on('will-navigate', (_event, url) => navigatedAway.push(url));
|
||||
|
||||
await win.webContents.executeJavaScript(
|
||||
`document.querySelector('a.file-ref[data-file-path="src/app.ts"]').click()`, true,
|
||||
);
|
||||
await delay(300);
|
||||
|
||||
assert(openCalls.length === 1, `clicking a reference calls file-ref:open once (got ${openCalls.length})`);
|
||||
assert(openCalls[0]?.path === 'src/app.ts', 'click sends the parsed path');
|
||||
assert(openCalls[0]?.line === 40, 'click sends the line as a number');
|
||||
assert(openCalls[0]?.cwd === cwd, 'click sends the message cwd');
|
||||
assert(openCalls[0]?.sessionId === sessionId, 'click sends the session id');
|
||||
assert(navigatedAway.length === 0, 'clicking a reference never navigates the window');
|
||||
|
||||
win.destroy();
|
||||
}
|
||||
|
||||
app.whenReady()
|
||||
.then(run)
|
||||
.catch(error => {
|
||||
failures++;
|
||||
console.error(error.stack || error);
|
||||
})
|
||||
.finally(() => {
|
||||
for (const channel of channels) ipcMain.removeHandler(channel);
|
||||
app.exit(failures ? 1 : 0);
|
||||
});
|
||||
Reference in New Issue
Block a user