diff --git a/scripts/check-note-results.mjs b/scripts/check-note-results.mjs
index 3ff6a87..81a055c 100644
--- a/scripts/check-note-results.mjs
+++ b/scripts/check-note-results.mjs
@@ -30,10 +30,13 @@ const items = [
{ id: 'request-2', taskId: task.id, type: 'user', text: '', createdAt: 3, meta: { notes: [{ ...note('Keep the technical term.', 10, 'A shorter technical sentence.'), where: 'Round 1 · Page 1 · main.tex line 10', anchor: { round: 1, sources: [{ file: 'main.tex', lines: [10, 12] }] } }] } },
];
const runs = [{ id: 'request-1', taskId: task.id, reviewRound: 1, status: 'completed' }, { id: 'request-2', taskId: task.id, reviewRound: 2, status: 'completed' }];
+// Several annotations can legitimately share a PDF group. Its links stay
+// compact and individually usable instead of wrapping into tall cards.
+items[2].meta.notes.push(...['Keep the definition precise.', 'Avoid unnecessary repetition.'].map(comment => ({ ...items[2].meta.notes[0], comment })));
const unit = (id, line, old, revised) => ({ id, file: 'main.tex', kind: 'text', status: 'modified', old_start: line, old_end: line + 2, new_start: line, new_end: line + 2, old_text: old, new_text: revised });
-const units = { 1: [unit('c1', 10, 'The original technical sentence.', 'A shorter technical sentence.'), unit('c2', 20, 'A second original sentence.', 'Our contribution is now explicit.')], 2: [unit('c1', 11, 'A shorter technical sentence.', 'A shorter sentence preserving the technical term.')] };
+const units = { 1: [unit('c1', 10, 'The original technical sentence.', 'A shorter technical sentence.'), unit('c2', 20, 'A second original sentence.', 'Our contribution is now explicit.'), unit('c3', 12, 'The original technical sentence.', 'A clearer technical sentence.')], 2: [unit('c1', 11, 'A shorter technical sentence.', 'A shorter sentence preserving the technical term.')] };
const rounds = Object.fromEntries([1, 2].map(round => [round, { round, units: units[round], sections: [], loose: units[round].map(unit => ({ kind: 'change', units: [unit.id], before: [{ text: [{ text: unit.old_text }] }] })) }]));
-const decisions = { 1: { c1: 'pending', c2: 'rejected' }, 2: { c1: 'pending' } };
+const decisions = { 1: { c1: 'pending', c2: 'rejected', c3: 'accepted' }, 2: { c1: 'pending' } };
const client = { get: async route => route.includes('/rounds/') ? rounds[Number(route.split('/').at(-1))] : { decisions: decisions[Number(new URL(route, origin).searchParams.get('round'))] } };
let failResults = true;
const optionWrites = [];
@@ -89,7 +92,7 @@ await page.route('**/review/**', async route => {
const url = new URL(route.request().url());
if (url.pathname.includes('/rounds/')) return route.fulfill({ json: rounds[Number(url.pathname.split('/').at(-1))] });
const round = Number(url.searchParams.get('round')) || 2;
- const html = `
${units[round].map((unit, index) => `
Change ${index + 1}main.tex:${unit.old_start}–${unit.old_end}${unit.old_text}
${unit.new_text}
`).join('')}
`;
+ const html = `${units[round].map((unit, index) => `
Change ${index + 1}main.tex:${unit.old_start}–${unit.old_end}${unit.old_text}
${unit.new_text}
`).join('')}
`;
return route.fulfill({ contentType: 'text/html', body: rewriteReviewHtml(html, '/review/paper') });
});
const frame = () => page.frameLocator('.review-frame');
@@ -117,7 +120,9 @@ try {
await frame().locator('html[data-papilot-round="1"]').waitFor();
await frame().locator('.papilot-linked-change[data-stop="1"]').waitFor();
assert.equal(await frame().locator('.papilot-note-options button').count(), 0, 'Historical changes do not advertise unavailable alternatives');
- assert.equal(await controls().locator('.review-note-position').textContent(), '2 / 3');
+ assert.equal(await controls().locator('.review-note-position').textContent(), 'Note 2 / 3');
+ await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '1');
+ await expect(controls().locator('.note-review-targets')).toHaveCount(0);
assert.equal(await controls().getByRole('button', { name: 'Previous note', exact: true }).isEnabled(), true);
assert.equal(await controls().getByRole('button', { name: 'Next note', exact: true }).isEnabled(), true);
assert.match(await page.locator('.review-frame').getAttribute('src'), /round=1&history=1/);
@@ -125,12 +130,35 @@ try {
decisions[1].c1 = 'accepted';
await frame().locator('body').evaluate(element => element.ownerDocument.defaultView.dispatchEvent(new Event('latex-review:decided')));
await sent(0).click();
- assert.equal(await controls().locator('.review-note-position').textContent(), '1 / 3');
+ assert.equal(await controls().locator('.review-note-position').textContent(), 'Note 1 / 3');
+ await expect(frame().locator('.papilot-linked-change')).toHaveCount(2);
+ await frame().locator('.change[data-stop="2"] .papilot-note-links button').click();
+ await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '2');
+ const selectedColor = await frame().locator('.papilot-linked-target').evaluate(element => getComputedStyle(element).outlineColor);
+ assert.equal(await controls().evaluate(element => getComputedStyle(element).borderTopColor), selectedColor, 'The drawer and its exact source change share a selection accent');
+ assert.notEqual(await frame().locator('.change[data-stop="0"] .change-head').evaluate(element => getComputedStyle(element).backgroundColor), await frame().locator('.papilot-linked-target .change-head').evaluate(element => getComputedStyle(element).backgroundColor), 'Other related fragments do not look like the selected target');
+ assert.ok(await frame().locator('.papilot-linked-target').evaluate(element => {
+ const bounds = element.getBoundingClientRect();
+ return bounds.top < window.innerHeight && bounds.bottom > 0;
+ }), 'A note badge reveals its own change instead of the note’s first change');
+ await frame().locator('.change[data-stop="0"] .papilot-note-links button').click();
+ await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '0');
+ await frame().locator('.change[data-stop="2"] .papilot-note-links button').click();
+ await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '2');
+ const linkedHeader = await frame().locator('.papilot-linked-target .change-head').boundingBox();
+ const linkedDrawer = await controls().boundingBox();
+ assert.ok(linkedHeader.y + linkedHeader.height <= linkedDrawer.y, 'The linked header stays above the drawer');
assert.equal(await controls().getByRole('button', { name: 'Previous note', exact: true }).isEnabled(), false);
const currentNote = page.locator('[data-note-result="request-2:0"]');
await currentNote.click();
await frame().locator('html[data-papilot-round="2"]').waitFor();
await frame().locator('.papilot-linked-change[data-stop="0"]').waitFor();
+ await expect(frame().locator('.papilot-linked-target')).toHaveAttribute('data-stop', '0');
+ const noteLinks = frame().locator('.change[data-stop="0"] .papilot-note-links');
+ await expect(noteLinks.locator('button')).toHaveCount(3);
+ await expect(noteLinks.locator('svg')).toHaveCount(1);
+ const linkBounds = await noteLinks.locator('button').evaluateAll(buttons => buttons.map(button => { const box = button.getBoundingClientRect(); return { top: box.top, bottom: box.bottom }; }));
+ assert.ok(linkBounds.every(box => box.top === linkBounds[0].top && box.bottom === linkBounds[0].bottom), 'Shared note numbers stay on one compact row');
assert.doesNotMatch(await page.locator('.review-frame').getAttribute('src'), /[?&](history|round)=/);
assert.deepEqual(await page.locator('[data-note-result]').allTextContents(), original, 'review operations never add content to original messages');
await controls().getByRole('region', { name: 'Complete PDF comparison', exact: true }).waitFor();
@@ -181,17 +209,21 @@ try {
await controls().waitFor({ state: 'hidden' });
await frame().getByRole('button', { name: 'Show note 1: Keep the technical term.', exact: true }).click();
await controls().waitFor({ state: 'visible' });
+ await controls().getByRole('region', { name: 'Complete PDF comparison', exact: true }).waitFor();
fs.mkdirSync('test-results', { recursive: true });
await page.screenshot({ path: 'test-results/note-results-desktop.png', fullPage: true });
+ await page.emulateMedia({ colorScheme: 'light' });
+ await page.screenshot({ path: 'test-results/note-results-desktop-light.png', fullPage: true });
+ await page.emulateMedia({ colorScheme: 'dark' });
await page.reload();
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: 'Chat', exact: true }).click();
await sent(0).click();
await frame().locator('.papilot-linked-change[data-stop="0"]').waitFor();
- assert.equal(await controls().locator('.review-note-position').textContent(), '1 / 3');
+ assert.equal(await controls().locator('.review-note-position').textContent(), 'Note 1 / 3');
assert.equal(await page.locator('.review-frame').isVisible(), true);
- await frame().getByRole('button', { name: 'Show note 1: Shorten the opening.', exact: true }).click();
+ await frame().getByRole('button', { name: 'Show note 1: Shorten the opening.', exact: true }).first().click();
assert.equal(await page.locator('.review-frame').isVisible(), true, 'PDF note badges keep the author in the review');
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 390);
const box = await controls().boundingBox();
diff --git a/scripts/check-task-model.mjs b/scripts/check-task-model.mjs
index cdc18f9..f2e0e1a 100644
--- a/scripts/check-task-model.mjs
+++ b/scripts/check-task-model.mjs
@@ -121,6 +121,28 @@ try {
const box = await taskModelMenu.boundingBox();
assert.ok(box.x >= 0 && box.x + box.width <= 390);
await page.screenshot({ path: 'test-results/task-model-mobile.png' });
+ await taskModelMenu.getByRole('combobox').press('Escape');
+ Object.assign(runtime, { connected: false, authenticated: false, error: null });
+ await page.evaluate(() => window.dispatchEvent(new Event('online')));
+ const connection = page.locator('.composer-wrap > .agent-connection');
+ await expect(connection).toHaveAttribute('data-state', 'connecting');
+ await expect(connection.getByRole('status')).toContainText('Connecting to Codex');
+ assert.equal(await connection.locator('.agent-connection-message > svg').count(), 0, 'Normal startup has no warning icon');
+ assert.equal(await connection.evaluate(element => getComputedStyle(element).backgroundColor), 'rgba(0, 0, 0, 0)', 'Normal startup has no warning card background');
+ await page.screenshot({ path: 'test-results/agent-connecting-mobile.png' });
+ Object.assign(runtime, { error: 'Connection refused: The agent did not respond.' });
+ await page.evaluate(() => window.dispatchEvent(new Event('online')));
+ await expect(connection).toHaveAttribute('data-state', 'error');
+ await expect(connection.getByRole('button', { name: 'Reconnect', exact: true })).toBeEnabled();
+ await connection.getByText('Connection details', { exact: true }).click();
+ await expect(connection.getByText(runtime.error, { exact: true })).toBeVisible();
+ await page.screenshot({ path: 'test-results/agent-connection-error-mobile.png' });
+ Object.assign(runtime, { connected: true, authenticated: false, error: null });
+ await page.evaluate(() => window.dispatchEvent(new Event('online')));
+ await expect(connection.getByRole('status')).toHaveText('Sign-in required');
+ Object.assign(runtime, { connected: true, authenticated: true, error: null });
+ await page.evaluate(() => window.dispatchEvent(new Event('online')));
+ await expect(connection).toHaveCount(0);
assert.deepEqual(errors, []);
console.log('PASS: Task model and effort stay editable within the same agent, persist, serialize changes, recover from save failures and fit mobile');
diff --git a/src/web/agent-picker.tsx b/src/web/agent-picker.tsx
index 5136f44..d13192c 100644
--- a/src/web/agent-picker.tsx
+++ b/src/web/agent-picker.tsx
@@ -15,11 +15,12 @@ export function ModelLabel({ model, effort, fast }: { model: string; effort?: st
export function AgentConnectionNotice({ name, runtime, reconnecting, onReconnect }: { name: string; runtime?: RuntimeStatus; reconnecting: boolean; onReconnect: () => void }) {
if (!runtime || (runtime.connected && runtime.authenticated)) return null;
const error = runtime.error || '', first = error.split(/:\s|\.\s/)[0];
+ const connecting = runtime.installed !== false && !error && !runtime.connected;
// An agent that was not found is not connecting: say so until it is installed and Reconnect finds it.
const message = runtime.installed === false ? t('{agent} was not found on this computer. Install or set it up, then select Reconnect.', { agent: name })
- : error ? (first.length <= 120 ? first : t('{agent} is unavailable', { agent: name })) : t('Connecting to {agent}…', { agent: name });
- return
-
{message}
+ : error ? (first.length <= 120 ? first : t('{agent} is unavailable', { agent: name })) : runtime.connected ? t('Sign-in required') : t('Connecting to {agent}…', { agent: name });
+ return
+
{connecting ? : }{message}
{error && error !== message &&
{t('Connection details')}
{error}
}
;
diff --git a/src/web/note-results.tsx b/src/web/note-results.tsx
index 96d6946..f9b5d3a 100644
--- a/src/web/note-results.tsx
+++ b/src/web/note-results.tsx
@@ -194,7 +194,7 @@ export function NoteReviewControls({ doc }: { doc: Document | null }) {
const frame = doc.defaultView?.frameElement?.getBoundingClientRect();
const viewport = element.parentElement!.getBoundingClientRect();
const bottom = viewport.bottom - element.offsetHeight - 12 - (frame?.top || 0);
- const marks = [...paper.querySelectorAll
('.papilot-linked-change .pdf-focus-marks rect')].map(mark => mark.getBoundingClientRect());
+ const marks = [...paper.querySelectorAll('.papilot-linked-target .pdf-focus-marks rect')].map(mark => mark.getBoundingClientRect());
if (!marks.length) return;
const top = Math.min(...marks.map(mark => mark.top)), end = Math.max(...marks.map(mark => mark.bottom));
const visibleTop = paper.getBoundingClientRect().top + 48;
@@ -233,7 +233,7 @@ export function NoteReviewControls({ doc }: { doc: Document | null }) {
event.preventDefault(); move(event.key === 'ArrowLeft' ? -1 : 1);
}}>
- {index + 1} / {siblings.length}
+ {t('Note {n}', { n: index + 1 })} / {siblings.length}
;
const emptyHeader = {navigation}
;
diff --git a/src/web/review-note-connections.tsx b/src/web/review-note-connections.tsx
index 381ba52..b58ee1a 100644
--- a/src/web/review-note-connections.tsx
+++ b/src/web/review-note-connections.tsx
@@ -40,16 +40,28 @@ export function ReviewNoteConnections({ doc, round }: { doc: Document | null; ro
scan();
const observer = new MutationObserver(scan); observer.observe(doc.body, { childList: true, subtree: true });
doc.defaultView?.addEventListener('papilot:round', scan);
- return () => { observer.disconnect(); doc.defaultView?.removeEventListener('papilot:round', scan); slots.forEach((pair, element) => { pair.slot.remove(); pair.optionsSlot.remove(); element.classList.remove('papilot-linked-change'); }); };
+ return () => { observer.disconnect(); doc.defaultView?.removeEventListener('papilot:round', scan); slots.forEach((pair, element) => { pair.slot.remove(); pair.optionsSlot.remove(); element.classList.remove('papilot-linked-change', 'papilot-linked-target'); }); };
}, [doc, links?.notes, round]);
useEffect(() => {
const focus = links?.focus;
const selected = links?.notes.find(note => note.key === focus?.key);
- for (const place of places) place.element.classList.toggle('papilot-linked-change', Boolean(links?.scope === 'note' && selected && place.notes.some(note => note.key === selected.key)));
+ for (const place of places) {
+ const linked = Boolean(links?.scope === 'note' && selected && place.notes.some(note => note.key === selected.key));
+ place.element.classList.toggle('papilot-linked-change', linked);
+ place.element.classList.toggle('papilot-linked-target', linked && place.round === focus?.change.round && place.stop === focus.change.stop);
+ }
if (!doc || !focus || (jumped.current?.doc === doc && jumped.current.sequence === focus.sequence)) return;
- const place = places.find(place => place.round === focus.change.round && place.notes.some(note => note.key === focus.key));
+ const place = places.find(place => place.round === focus.change.round && place.stop === focus.change.stop && place.notes.some(note => note.key === focus.key));
if (!place) return;
- place.element.click(); place.element.scrollIntoView({ block: 'center' }); place.element.focus({ preventScroll: true });
+ place.element.click();
+ // Scroll only the PDF pane: scrollIntoView can also move the host page
+ // horizontally or leave the target behind the bottom drawer.
+ const paper = doc.getElementById('paper');
+ if (paper) {
+ const padding = parseFloat(doc.defaultView?.getComputedStyle(paper).scrollPaddingTop || '') || 0;
+ paper.scrollBy({ top: place.element.getBoundingClientRect().top - paper.getBoundingClientRect().top - padding - 12, behavior: 'instant' });
+ }
+ place.element.focus({ preventScroll: true });
jumped.current = { doc, sequence: focus.sequence };
}, [doc, places, links?.focus, links?.scope]);
useEffect(() => {
@@ -66,20 +78,31 @@ export function ReviewNoteConnections({ doc, round }: { doc: Document | null; ro
return () => { target.removeEventListener('papilot:round', send); target.removeEventListener('papilot:note-focus-ready', send); };
}, [doc, round, links?.focus, links?.notes, links?.preview, links?.scope]);
if (!doc || !links) return null;
+ const focusAt = (place: Place, note: NoteResult) => {
+ const change = note.changes.find(change => change.round === place.round && change.stop === place.stop);
+ if (change) latest.current?.showChange(note.key, change);
+ else latest.current?.focusNote(note.key);
+ };
return <>{createPortal(, doc.head)}{places.map(place => createPortal(<>{place.notes.map(note => )}>, place.slot, `${place.round}:${place.stop}`))}{places.map(place => {
+ .papilot-linked-change>.change-head{box-shadow:inset 3px 0 color-mix(in srgb,var(--primary) 35%,transparent)}
+ .papilot-linked-target{outline:2px solid var(--primary);outline-offset:-2px}
+ .papilot-linked-target>.change-head{background:color-mix(in srgb,var(--primary) 12%,var(--bg));box-shadow:inset 3px 0 var(--primary)}
+ .papilot-linked-target>.change-head .change-number{color:var(--primary);font-weight:600}
+ @media(pointer:coarse){.papilot-note-links button{min-height:32px;min-width:28px}}
+ `}, doc.head)}{places.map(place => createPortal(<>{place.notes.map(note => )}>, place.slot, `${place.round}:${place.stop}`))}{places.map(place => {
const available = place.notes.filter(note => links.alternativeCounts[note.key] > 0);
const note = available.find(note => note.key === links.focus?.key) || available[0];
- return createPortal(note ? : null, place.optionsSlot, `options:${place.round}:${place.stop}`);
+ return createPortal(note ? : null, place.optionsSlot, `options:${place.round}:${place.stop}`);
})}>;
}
diff --git a/src/web/style.css b/src/web/style.css
index 62155a6..a13db72 100644
--- a/src/web/style.css
+++ b/src/web/style.css
@@ -520,14 +520,18 @@ body.resizing iframe{pointer-events:none}
.task-permissions-hint{color:var(--text-2)}
.task-permissions fieldset:disabled .task-permission-option{opacity:.6;cursor:default}
.agent-status-mark{display:flex;flex:none;color:var(--amber-text)}
-.agent-connection{margin-top:10px;padding:10px 12px;border:1px solid var(--amber-line);border-radius:10px;background:var(--amber-soft);font-size:12px;line-height:1.5;color:var(--text-2)}
-.agent-connection-message{display:flex;align-items:flex-start;gap:7px;color:var(--amber-text)}
+.agent-connection{display:flex;align-items:center;flex-wrap:wrap;gap:4px 10px;margin-top:8px;padding:5px 2px;font-size:12px;line-height:1.5;color:var(--text-2)}
+.agent-connection[data-state="error"]{padding:8px 10px;border-left:2px solid var(--amber-line);border-radius:2px;background:var(--amber-soft)}
+.agent-connection-message{display:flex;align-items:flex-start;flex:1;min-width:0;gap:7px}
+.agent-connection[data-state="error"] .agent-connection-message{color:var(--amber-text)}
.agent-connection-message>svg{flex:none;margin-top:2px}
-.agent-connection-message>span{min-width:0;overflow-wrap:anywhere}
-.agent-connection details{margin:6px 0 0 22px}
+.agent-connection-message>.spinner{flex:none;width:12px;height:12px;margin-top:3px}
+.agent-connection-message>span:last-child{min-width:0;overflow-wrap:anywhere}
+.agent-connection details{order:3;flex-basis:100%;margin:2px 0 0 21px}
.agent-connection summary{cursor:pointer;font-size:11px}
.agent-connection p{margin:6px 0;overflow-wrap:anywhere;font-size:12px}
-.agent-connection>button{display:flex;align-items:center;gap:5px;margin:6px 0 -2px 16px;padding:3px 6px;border-radius:5px;color:var(--accent);font-size:12px;font-weight:500}
+.agent-connection>button{display:flex;align-items:center;flex:none;gap:4px;padding:3px 5px;border-radius:4px;color:var(--accent);font-size:11px}
+.agent-connection[data-state="connecting"]>button{color:var(--text-3)}
.agent-connection>button:hover:not(:disabled){background:var(--bg-hover)}
.agent-connection>button:disabled{opacity:.6;cursor:wait}
.composer-wrap>.agent-connection{max-width:760px;margin:0 auto 8px}
@@ -1063,8 +1067,10 @@ body.resizing iframe{pointer-events:none}
.sent-notes li[data-note-result] { cursor: pointer; }
.sent-notes li[data-note-result]:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.review-note-controls { position: absolute; z-index: 5; inset: auto 0 0; padding: 0 0 env(safe-area-inset-bottom, 0px); border: 0; border-top: 1px solid var(--line-strong); border-radius: 0; background: var(--bg); box-shadow: 0 -4px 16px rgb(0 0 0 / .06); max-height: min(44%, 300px); display: flex; flex-direction: column; overflow: hidden; transform: translateY(100%); transition: transform .26s var(--ease); }
-.review-note-controls[data-open="true"] { transform: translateY(0); }
+.review-note-controls[data-open="true"] { transform: translateY(0); border-top: 2px solid var(--primary); }
.review-note-controls[data-open="false"] { pointer-events: none; }
+.review-note-controls[data-open="true"] .note-option-navigation { background: color-mix(in srgb, var(--primary) 8%, var(--bg)); }
+.review-note-controls[data-open="true"] .review-note-position { color: var(--primary); }
@starting-style { .review-note-controls[data-open="true"] { transform: translateY(100%); } }
@media (prefers-reduced-motion: reduce) { .review-note-controls { transition: none; } }
.review-note-pager { display: flex; align-items: center; flex-shrink: 0; gap: 2px; margin: 0 0 4px auto; color: var(--text-2); }