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
+1
View File
@@ -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'),
+935 -2760
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -22,7 +22,8 @@
"test:electron": "electron-vite build && electron --no-sandbox tests/electron-concurrency.mjs",
"test:electron:images": "electron-vite build && electron --no-sandbox tests/electron-session-images.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",
@@ -71,15 +72,15 @@
]
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"better-sqlite3": "^13.0.2",
"chokidar": "^4.0.3"
},
"devDependencies": {
"@tanstack/vue-virtual": "^3.13.32",
"@types/better-sqlite3": "^7.6.13",
"@vitejs/plugin-vue": "^5.0.0",
"electron": "^33.0.0",
"electron-builder": "^25.0.0",
"electron": "^43.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"vite": "^6.0.0",
"vue": "^3.4.0",
+90
View File
@@ -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,
};
+78
View File
@@ -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,
+2
View File
@@ -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'),
+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); }
+203
View File
@@ -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);
});
+6
View File
@@ -19,6 +19,8 @@ export interface SessionDetailMessage {
text: string | null;
content_type: string | null;
is_meta: 0 | 1;
session_id: string | null;
cwd: string | null;
turn_duration_ms: number | null;
}
@@ -365,6 +367,10 @@ function assembleTranscriptRecords(records: Iterable<TranscriptRecord>): Session
text: record.text,
content_type: record.content_type,
is_meta: record.is_meta,
// Both kept per message, not per session: the working directory can change
// mid-session, and the session id scopes which roots a file reference may resolve in.
session_id: typeof record.session_id === 'string' ? record.session_id : null,
cwd: typeof record.cwd === 'string' ? record.cwd : null,
turn_duration_ms: typeof (record as MessageRecord & { turn_duration_ms?: unknown }).turn_duration_ms === 'number'
? (record as MessageRecord & { turn_duration_ms: number }).turn_duration_ms
: null,
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
buildEditorUrl,
fileReferenceCandidates,
normalizeRoots,
resolveFileReference,
} from '../app/src/main/file-reference.ts';
function tempProject() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'obelisk-file-ref-'));
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.writeFileSync(path.join(root, 'src', 'app.ts'), 'export const a = 1;\n');
return fs.realpathSync(root);
}
test('normalizeRoots keeps absolute paths and drops the rest', () => {
assert.deepEqual(normalizeRoots(['/tmp/a', 'relative', '', null, undefined, '/tmp/a']), ['/tmp/a']);
});
test('candidates cover absolute, cwd-relative and project-relative shapes', () => {
const candidates = fileReferenceCandidates({ rawPath: 'src/app.ts', cwd: '/tmp/proj' });
assert.ok(candidates.includes(path.join('/tmp/proj', 'src/app.ts')));
// A leading slash in transcripts usually means "project root", not filesystem root.
const rooted = fileReferenceCandidates({ rawPath: '/src/app.ts', cwd: '/tmp/proj' });
assert.ok(rooted.includes('/src/app.ts'));
assert.ok(rooted.includes(path.join('/tmp/proj', 'src/app.ts')));
});
test('resolves a relative reference against the message cwd', () => {
const root = tempProject();
assert.equal(
resolveFileReference({ rawPath: 'src/app.ts', cwd: root, roots: [] }),
path.join(root, 'src', 'app.ts'),
);
});
test('refuses paths outside the session roots', () => {
const root = tempProject();
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'obelisk-outside-'));
fs.writeFileSync(path.join(outside, 'secret.txt'), 'nope\n');
assert.equal(resolveFileReference({ rawPath: path.join(outside, 'secret.txt'), cwd: root }), null);
assert.equal(resolveFileReference({ rawPath: '../../etc/hosts', cwd: root }), null);
});
test('refuses a symlink that escapes the session roots', () => {
const root = tempProject();
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'obelisk-outside-'));
const secret = path.join(outside, 'secret.txt');
fs.writeFileSync(secret, 'nope\n');
fs.symlinkSync(secret, path.join(root, 'escape.txt'));
assert.equal(resolveFileReference({ rawPath: 'escape.txt', cwd: root }), null);
});
test('refuses directories, missing files and rootless queries', () => {
const root = tempProject();
assert.equal(resolveFileReference({ rawPath: 'src', cwd: root }), null);
assert.equal(resolveFileReference({ rawPath: 'src/nope.ts', cwd: root }), null);
assert.equal(resolveFileReference({ rawPath: 'src/app.ts', cwd: null, roots: [] }), null);
});
test('builds editor URLs with line and column', () => {
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: 42 }), 'vscode://file/p/a.ts:42');
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: 42, column: 7 }), 'vscode://file/p/a.ts:42:7');
assert.equal(buildEditorUrl({ filePath: '/p/a.ts' }), 'vscode://file/p/a.ts');
assert.equal(buildEditorUrl({ scheme: 'cursor', filePath: '/p/a.ts', line: 3 }), 'cursor://file/p/a.ts:3');
});
test('falls back to the default scheme and encodes spaces', () => {
assert.equal(buildEditorUrl({ scheme: 'evil:', filePath: '/p/a.ts' }), 'vscode://file/p/a.ts');
assert.equal(buildEditorUrl({ filePath: '/p/a b.ts', line: 1 }), 'vscode://file/p/a%20b.ts:1');
});
test('ignores a zero or negative line number', () => {
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: 0 }), 'vscode://file/p/a.ts');
assert.equal(buildEditorUrl({ filePath: '/p/a.ts', line: -3 }), 'vscode://file/p/a.ts');
});
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
hrefToPath,
mayContainFileReference,
isInlineCodeReference,
isLocalHref,
parseFileReference,
} from '../app/src/renderer/src/file-references.mjs';
test('splits the Codex line and column suffix', () => {
assert.deepEqual(parseFileReference('/Users/me/proj/a.ts:162'), {
path: '/Users/me/proj/a.ts', line: 162, column: null, endLine: null,
});
assert.deepEqual(parseFileReference('/Users/me/proj/a.ts:42:7'), {
path: '/Users/me/proj/a.ts', line: 42, column: 7, endLine: null,
});
});
test('splits a line range', () => {
assert.deepEqual(parseFileReference('taskTree.ts:182-185'), {
path: 'taskTree.ts', line: 182, column: null, endLine: 185,
});
});
test('keeps a reference without a suffix intact', () => {
assert.deepEqual(parseFileReference('/Users/me/proj/README.md'), {
path: '/Users/me/proj/README.md', line: null, column: null, endLine: null,
});
});
test('rejects empty input', () => {
assert.equal(parseFileReference(''), null);
assert.equal(parseFileReference(null), null);
});
test('recognises local hrefs only', () => {
assert.ok(isLocalHref('/Users/me/a.ts'));
assert.ok(isLocalHref('file:///Users/me/a.ts'));
assert.ok(isLocalHref('C:/proj/a.ts'));
assert.ok(!isLocalHref('https://example.com/a.ts'));
assert.ok(!isLocalHref('#anchor'));
assert.ok(!isLocalHref('mailto:x@y.z'));
});
test('inline code becomes a reference only with an extension and a line', () => {
assert.ok(isInlineCodeReference('src/hooks/hook.ts:40'));
assert.ok(isInlineCodeReference('packages/kernel/src/router.ts:32'));
assert.ok(isInlineCodeReference('taskTree.ts:182-185'));
assert.ok(isInlineCodeReference('/src/tools/openai-categories.ts:198-201'));
});
test('ordinary inline code is never turned into a link', () => {
assert.ok(!isInlineCodeReference('package.json'));
assert.ok(!isInlineCodeReference('useState'));
assert.ok(!isInlineCodeReference('npm run build'));
assert.ok(!isInlineCodeReference('a.ts'));
assert.ok(!isInlineCodeReference('12:30'));
assert.ok(!isInlineCodeReference('const x = obj.a[0]:1'));
});
test('decodes file:// URLs but leaves plain paths byte-for-byte', () => {
assert.equal(hrefToPath('file:///Users/me/a%20file.ts'), '/Users/me/a file.ts');
// A literal percent in a filename must survive: decoding every href would corrupt it.
assert.equal(hrefToPath('/Users/me/100%.md'), '/Users/me/100%.md');
assert.equal(hrefToPath('/Users/me/a%20b.md'), '/Users/me/a%20b.md');
});
test('returns empty string for a malformed file URL', () => {
assert.equal(hrefToPath('file://%E0%A4%A'), '');
});
test('pre-filter skips markup that cannot hold a reference', () => {
assert.ok(!mayContainFileReference('<p>plain prose with no markup</p>'));
assert.ok(!mayContainFileReference(''));
assert.ok(!mayContainFileReference(null));
});
test('pre-filter admits anything with a link or inline code', () => {
assert.ok(mayContainFileReference('<p><a href="/a.ts:1">x</a></p>'));
assert.ok(mayContainFileReference('<p><code>src/a.ts:1</code></p>'));
assert.ok(mayContainFileReference('<pre><code class="language-ts">x</code></pre>'));
});
+12 -10
View File
@@ -145,6 +145,8 @@ async function loadMainForWindowFlags(flags) {
this.devToolsOpened = false;
this.webContents = {
on() {},
setWindowOpenHandler() {},
getURL() { return ''; },
setZoomLevel() {},
openDevTools: () => { this.devToolsOpened = true; },
send() {},
@@ -222,7 +224,7 @@ test('main process watches every root declared by the built-in provider registry
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
@@ -295,7 +297,7 @@ test('main process forwards committed IDs without reopening after a deferred bui
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() { notifications += 1; } };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() { notifications += 1; } };
}
loadFile() {}
loadURL() {}
@@ -374,7 +376,7 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
@@ -456,7 +458,7 @@ test('usage IPC aggregates normalized tokens across all indexed providers', asyn
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
@@ -540,7 +542,7 @@ test('main process migrates an existing app database before source-filtered IPC
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
@@ -606,7 +608,7 @@ test('main process keeps schema and memory mutations behind the writer lease', a
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
@@ -676,7 +678,7 @@ test('closing the last macOS window releases background resources until activati
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
windows.push(this);
}
loadFile() {}
@@ -801,7 +803,7 @@ test('settings rebuild reopens the database from the configured Claude path', as
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
@@ -929,7 +931,7 @@ test('settings rebuild keeps the existing database after a worker failure', asyn
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
@@ -1033,7 +1035,7 @@ test('settings rebuild cancels an in-flight background build instead of waiting
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
this.webContents = { on() {}, setWindowOpenHandler() {}, getURL() { return ''; }, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}