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
@@ -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