diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f82cdf6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Normalize line endings for this cross-platform CLI. +* text=auto eol=lf + +# dist/ is compiled output, committed on purpose: this is a Claude Code plugin +# distributed as a GitHub repo, and plugin installs fetch repo contents with no +# build step. If dist/ is not committed, every install breaks. Do not gitignore it. +# +# It is still generated code, not something a human edits by hand, so mark it as +# such: GitHub collapses it in PR diffs and excludes it from language stats, and +# -diff keeps it out of local `git diff` output too. CI still enforces that dist/ +# matches a fresh build of src/, see .github/workflows/ci.yml. +dist/** linguist-generated=true -diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00b9782..b9705af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,13 @@ name: CI on: push: + branches: [main] pull_request: + branches: [main] + +# Minimal permissions: the workflow only checks out code and runs local scripts. +permissions: + contents: read jobs: test: @@ -10,13 +16,39 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [18, 20] + node: [22, 24, 26] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - - run: npm install + cache: npm + - run: npm ci - run: npm run typecheck - run: npm run build - - run: node --test + - run: npm test + + # dist/ is committed on purpose (see .gitattributes), which also hides it from + # PR diffs. That makes stale compiled output easy to miss, so enforce the + # invariant here instead: a clean rebuild of src/ must produce byte-identical + # dist/ output. Runs on a single Node version only, there is nothing to gain + # from comparing build output across the matrix. + dist-drift: + name: Verify dist/ matches a fresh build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + - name: Fail if dist/ is stale + run: | + if git diff --exit-code -- dist/; then + echo "dist/ matches a fresh build of src/." + else + echo "::error::dist/ does not match a fresh build of src/. Run 'npm run build' locally and commit the updated dist/ output." + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b3186e5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +# This repo ships as a Claude Code plugin, distributed via the GitHub repo +# itself (see .claude-plugin/), not published to npm. release-please's job +# here is only to open the release PR, then on merge create the git tag, +# the GitHub Release, and the changelog. There is no publish step, unlike +# the sibling openapi-zod-ts monorepo's release workflow. +name: Release Please + +on: + push: + branches: [main] + workflow_dispatch: {} + +permissions: + contents: write # create tags and releases + issues: write # release-please's documented basic configuration requires this + pull-requests: write # create and update release PRs + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..484cfe6 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{ ".": "1.0.0" } diff --git a/README.md b/README.md index f1886db..6510cd0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Interactive HTML feedback loop for Claude Code agents. The agent opens an HTML f 2. `/plugin install loupe@codewithagents` 3. If prompted, run `/reload-plugins` to activate. -The skill is invoked as `/loupe:loupe`. Requires Node.js 18+ and has zero runtime dependencies. +The skill is invoked as `/loupe:loupe`. Requires Node.js 22+ and has zero runtime dependencies. ## Development @@ -51,7 +51,7 @@ node dist/cli.js poll /path/to/your/file.html --reply "I've updated the layout." ## Requirements -- Node.js 18 or newer. +- Node.js 22 or newer. - Zero runtime npm dependencies. ## License diff --git a/dist/browser/client-sdk.js b/dist/browser/client-sdk.js index 0665b37..8ca1bb7 100644 --- a/dist/browser/client-sdk.js +++ b/dist/browser/client-sdk.js @@ -7,6 +7,28 @@ let composeBox = null; let markerContainer = null; let markers = {}; let presenceWorking = false; +// Draft persistence: survives the artifact iframe being torn down and reloaded +// (the shell reloads the artifact whenever the underlying file changes on disk). +// sessionStorage is scoped to the tab + origin, not to a specific document instance, +// so it survives `artifactFrame.src = ...` reassignment as long as the iframe stays +// same-origin with the shell, which it is here (both served by the loupe server). +const DRAFT_KEY = 'loupe:compose-draft:' + key; +const TEXTAREA_MIN_HEIGHT = 56; +const TEXTAREA_MAX_HEIGHT = 200; +// Set while a compose box is open, to flush its latest state to sessionStorage +// on pagehide (fired just before the iframe document is torn down on reload). +let persistCurrentDraft = null; +// Cancels the currently open box's pending "focus was lost" flip (see the blur +// handler in openCompose). pagehide calls this before persisting, so a blur that +// only happened because the iframe document is mid-teardown never gets a chance +// to record "not focused" before we capture the final draft state. +let cancelPendingFocusFlip = null; +function clearPersistedDraft() { + try { + sessionStorage.removeItem(DRAFT_KEY); + } + catch (_e) { } +} // Inject pulse keyframe once at module load. const _pulseStyle = document.createElement('style'); _pulseStyle.textContent = '@keyframes loupe-pulse{0%,100%{box-shadow:0 2px 6px rgba(0,0,0,0.35),0 0 0 0 rgba(245,158,11,0.55)}50%{box-shadow:0 2px 6px rgba(0,0,0,0.35),0 0 0 5px rgba(245,158,11,0)}} .loupe-awaiting-pulse{animation:loupe-pulse 1.4s ease-in-out infinite;}'; @@ -172,14 +194,14 @@ function showSelOv(el) { selOv.style.display = 'block'; } /* ---- inline compose ---- */ -function openCompose(el, selector) { - if (composeBox) { - composeBox.remove(); - composeBox = null; - } - const contentSnippet = getContentSnippet(el); +// Keeps the compose box anchored under (or above, if it would overflow) its target +// element. Reads the box's actual rendered height instead of a fixed guess, since the +// textarea auto-grows as the user types; without that the box could grow past the +// bottom of the viewport once the flip decision was made against a stale height. +function positionComposeBox(el, box) { const r = el.getBoundingClientRect(); - const mg = 8, bw = 240, bh = 118; + const mg = 8, bw = 240; + const bh = box.offsetHeight || 118; let left = r.left; let top = r.bottom + mg; if (left + bw > window.innerWidth - mg) @@ -190,21 +212,95 @@ function openCompose(el, selector) { top = r.top - bh - mg; if (top < mg) top = mg; + box.style.left = `${left}px`; + box.style.top = `${top}px`; +} +// Grows the textarea with its content up to a max height, then lets it scroll +// internally (harmless now that the window scroll listener no longer closes the +// compose box on incidental scrolls originating inside loupe's own UI). +function autoGrowTextarea(ta) { + ta.style.height = `${TEXTAREA_MIN_HEIGHT}px`; + const next = Math.max(Math.min(ta.scrollHeight, TEXTAREA_MAX_HEIGHT), TEXTAREA_MIN_HEIGHT); + ta.style.height = `${next}px`; + ta.style.overflowY = ta.scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; +} +function openCompose(el, selector, restore) { + if (composeBox) { + composeBox.remove(); + composeBox = null; + } + cancelPendingFocusFlip?.(); + const contentSnippet = getContentSnippet(el); const box = document.createElement('div'); box.setAttribute('data-loupe-compose', '1'); - box.style.cssText = `position:fixed;z-index:2147483001;background:#1e293b;border:1.5px solid #3b82f6;border-radius:8px;padding:8px;width:240px;box-shadow:0 4px 20px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:5px;left:${left}px;top:${top}px;`; + box.style.cssText = 'position:fixed;z-index:2147483001;background:#1e293b;border:1.5px solid #3b82f6;border-radius:8px;padding:8px;width:240px;box-shadow:0 4px 20px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:5px;left:-9999px;top:-9999px;'; const lbl = document.createElement('div'); lbl.style.cssText = 'font-size:10px;color:#94a3b8;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; lbl.textContent = selector || 'element'; box.appendChild(lbl); const ta = document.createElement('textarea'); - ta.style.cssText = 'background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:6px;font-size:12px;resize:none;height:56px;outline:none;font-family:system-ui,sans-serif;width:100%;box-sizing:border-box;line-height:1.4;'; + ta.style.cssText = `background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:6px;font-size:12px;resize:none;height:${TEXTAREA_MIN_HEIGHT}px;overflow-y:hidden;outline:none;font-family:system-ui,sans-serif;width:100%;box-sizing:border-box;line-height:1.4;`; ta.placeholder = 'Type note, Enter to add...'; box.appendChild(ta); const hint = document.createElement('div'); hint.style.cssText = 'font-size:10px;color:#475569;line-height:1.3;'; hint.textContent = 'Enter: add Shift+Enter: newline Cmd+Enter: add & send all Esc: cancel'; box.appendChild(hint); + // Whether the textarea is focused, tracked explicitly instead of read from + // document.activeElement at persist time. By the time any blur handler (or the + // pagehide handler below) runs, activeElement has already moved on to whatever + // took focus (or to document.body), so reading it there would always report + // "not focused", even when the blur is really just an artifact of the iframe + // document being torn down for reload rather than a deliberate focus change. + let isFocused = restore ? restore.focus : true; + let focusFlipTimer = null; + function cancelFocusFlip() { + if (focusFlipTimer !== null) { + window.clearTimeout(focusFlipTimer); + focusFlipTimer = null; + } + } + function persistDraft() { + try { + const draft = { + selector, + contentSnippet, + text: ta.value, + selectionStart: ta.selectionStart, + selectionEnd: ta.selectionEnd, + focused: isFocused, + }; + sessionStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); + } + catch (_e) { } + } + ta.addEventListener('input', () => { + autoGrowTextarea(ta); + positionComposeBox(el, box); + persistDraft(); + }); + ta.addEventListener('keyup', persistDraft); + ta.addEventListener('click', persistDraft); + ta.addEventListener('focus', () => { + cancelFocusFlip(); + isFocused = true; + persistDraft(); + }); + ta.addEventListener('blur', () => { + // Defer the flip to false by one macrotask rather than applying it immediately. + // A genuine blur (e.g. focus moving to the parent shell's own sidebar input) has + // nothing racing it, so the flip lands a moment later as normal. A blur that is + // just a side effect of this document unloading for reload is followed by + // pagehide first, which cancels this timer, so isFocused (and the persisted + // draft) still says "focused" when we come back. Ambiguous cases resolve in + // favor of restoring focus, since losing it silently is the worse outcome. + cancelFocusFlip(); + focusFlipTimer = window.setTimeout(() => { + focusFlipTimer = null; + isFocused = false; + persistDraft(); + }, 0); + }); ta.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter' && e.shiftKey) @@ -232,7 +328,31 @@ function openCompose(el, selector) { }); document.body.appendChild(box); composeBox = box; - ta.focus(); + persistCurrentDraft = persistDraft; + cancelPendingFocusFlip = cancelFocusFlip; + // Settle the height first (it affects the top/bottom flip math), then place the + // box at its final position, and only then focus anything. Focusing an element + // still parked at its off-screen placeholder position risks the browser trying to + // scroll it into view, which would visibly jump the page (and, on restore, fire a + // spurious scroll event during init). + if (restore) { + ta.value = restore.text; + autoGrowTextarea(ta); + } + positionComposeBox(el, box); + if (restore) { + const len = ta.value.length; + try { + ta.setSelectionRange(Math.min(restore.selectionStart, len), Math.min(restore.selectionEnd, len)); + } + catch (_e) { } + if (restore.focus) + ta.focus(); + } + else { + ta.focus(); + } + persistDraft(); } function closeCompose() { if (composeBox) { @@ -242,6 +362,10 @@ function closeCompose() { if (selOv) selOv.style.display = 'none'; selectedEl = null; + cancelPendingFocusFlip?.(); + cancelPendingFocusFlip = null; + persistCurrentDraft = null; + clearPersistedDraft(); } /* ---- rAF tick ---- */ function tick() { @@ -251,6 +375,9 @@ function tick() { if (selectedEl && selOv && selOv.style.display !== 'none') { placeOv(selOv, selectedEl); } + if (composeBox && selectedEl) { + positionComposeBox(selectedEl, composeBox); + } requestAnimationFrame(tick); } requestAnimationFrame(tick); @@ -283,9 +410,70 @@ function checkAnchor(threadId, selector, contentSnippet) { } return { threadId, selector: null, orphaned: true }; } +/* ---- draft restoration across iframe reloads ---- */ +// Reopens an in-progress compose box after the shell reloads the artifact iframe +// (e.g. the agent just edited the file on disk). Reuses checkAnchor, the same +// stale-selector-to-element machinery used for orphan detection elsewhere, so a +// draft survives even if the reload changed the DOM enough to shift the selector. +function restoreDraftOnLoad() { + let raw = null; + try { + raw = sessionStorage.getItem(DRAFT_KEY); + } + catch (_e) { + return; + } + if (!raw) + return; + let draft; + try { + draft = JSON.parse(raw); + } + catch (_e) { + clearPersistedDraft(); + return; + } + const anchor = checkAnchor('__draft__', draft.selector || null, draft.contentSnippet || null); + if (anchor.orphaned || !anchor.selector) { + clearPersistedDraft(); + return; + } + const target = document.querySelector(anchor.selector); + if (!target) { + clearPersistedDraft(); + return; + } + selectedEl = target; + showSelOv(target); + openCompose(target, anchor.selector, { + text: draft.text, + selectionStart: draft.selectionStart, + selectionEnd: draft.selectionEnd, + focus: draft.focused, + }); +} +restoreDraftOnLoad(); +// Final flush before the iframe document is torn down, e.g. by the shell reloading +// the artifact after the agent edits the file on disk. Cancel any pending "focus was +// lost" flip first, so a blur that only fired as a side effect of this same teardown +// does not get to record "not focused" before we capture the final draft state. +window.addEventListener('pagehide', () => { + cancelPendingFocusFlip?.(); + persistCurrentDraft?.(); +}); /* ---- postMessage from parent shell ---- */ -window.addEventListener('scroll', () => { if (composeBox) - closeCompose(); }, true); +// Scroll events do not bubble, but this listener is capture-phase on window, so it +// still fires for scrolls inside descendants, including the compose textarea's own +// internal overflow scrolling once a note grows past its visible height. Ignore those +// entirely. A real page scroll no longer closes the compose box either: it stays +// anchored to its target element every frame via tick(), closing on scroll just to +// lose the user's draft is itself the bug we're fixing. +window.addEventListener('scroll', (e) => { + if (isLoupeEl(e.target)) + return; + if (composeBox && selectedEl) + positionComposeBox(selectedEl, composeBox); +}, true); window.addEventListener('message', (e) => { if (!e.data || typeof e.data !== 'object') return; diff --git a/package-lock.json b/package-lock.json index e356ac3..fd11bba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codewithagents/loupe", - "version": "1.0.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codewithagents/loupe", - "version": "1.0.1", + "version": "1.0.0", "license": "MIT", "bin": { "loupe": "dist/cli.js" @@ -14,6 +14,9 @@ "devDependencies": { "@types/node": "^26.3.0", "typescript": "^7.0.2" + }, + "engines": { + "node": ">=22" } }, "node_modules/@types/node": { diff --git a/package.json b/package.json index 2af8e2a..0de78bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codewithagents/loupe", - "version": "1.0.1", + "version": "1.0.0", "type": "module", "description": "Interactive HTML feedback loop for Claude Code agents", "main": "dist/cli.js", @@ -13,6 +13,9 @@ "test": "node --test test/loop.test.js" }, "license": "MIT", + "engines": { + "node": ">=22" + }, "homepage": "https://github.com/codewithagents/loupe", "repository": { "type": "git", diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..ade873a --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "bfbd3e9223b5500eb79de9fbda9c5d7a20e64666", + "packages": { + ".": { + "release-type": "node", + "component": "loupe", + "tag-separator": "--", + "extra-files": [ + { "type": "json", "path": ".claude-plugin/plugin.json", "jsonpath": "$.version" } + ] + } + } +} diff --git a/src/browser/client-sdk.ts b/src/browser/client-sdk.ts index 8d39983..023a88b 100644 --- a/src/browser/client-sdk.ts +++ b/src/browser/client-sdk.ts @@ -15,6 +15,45 @@ type MarkerState = 'done' | 'agent-last' | 'interim' | 'queued' | 'user-last' | let markers: Record = {}; let presenceWorking = false; +// Draft persistence: survives the artifact iframe being torn down and reloaded +// (the shell reloads the artifact whenever the underlying file changes on disk). +// sessionStorage is scoped to the tab + origin, not to a specific document instance, +// so it survives `artifactFrame.src = ...` reassignment as long as the iframe stays +// same-origin with the shell, which it is here (both served by the loupe server). +const DRAFT_KEY = 'loupe:compose-draft:' + key; +const TEXTAREA_MIN_HEIGHT = 56; +const TEXTAREA_MAX_HEIGHT = 200; + +interface PersistedDraft { + selector: string; + contentSnippet: string; + text: string; + selectionStart: number; + selectionEnd: number; + focused: boolean; +} + +interface ComposeRestoreOptions { + text: string; + selectionStart: number; + selectionEnd: number; + focus: boolean; +} + +// Set while a compose box is open, to flush its latest state to sessionStorage +// on pagehide (fired just before the iframe document is torn down on reload). +let persistCurrentDraft: (() => void) | null = null; + +// Cancels the currently open box's pending "focus was lost" flip (see the blur +// handler in openCompose). pagehide calls this before persisting, so a blur that +// only happened because the iframe document is mid-teardown never gets a chance +// to record "not focused" before we capture the final draft state. +let cancelPendingFocusFlip: (() => void) | null = null; + +function clearPersistedDraft(): void { + try { sessionStorage.removeItem(DRAFT_KEY); } catch (_e) {} +} + // Inject pulse keyframe once at module load. const _pulseStyle = document.createElement('style'); _pulseStyle.textContent = '@keyframes loupe-pulse{0%,100%{box-shadow:0 2px 6px rgba(0,0,0,0.35),0 0 0 0 rgba(245,158,11,0.55)}50%{box-shadow:0 2px 6px rgba(0,0,0,0.35),0 0 0 5px rgba(245,158,11,0)}} .loupe-awaiting-pulse{animation:loupe-pulse 1.4s ease-in-out infinite;}'; @@ -174,22 +213,43 @@ function showSelOv(el: Element): void { } /* ---- inline compose ---- */ -function openCompose(el: Element, selector: string): void { - if (composeBox) { composeBox.remove(); composeBox = null; } - const contentSnippet = getContentSnippet(el); +// Keeps the compose box anchored under (or above, if it would overflow) its target +// element. Reads the box's actual rendered height instead of a fixed guess, since the +// textarea auto-grows as the user types; without that the box could grow past the +// bottom of the viewport once the flip decision was made against a stale height. +function positionComposeBox(el: Element, box: HTMLDivElement): void { const r = el.getBoundingClientRect(); - const mg = 8, bw = 240, bh = 118; + const mg = 8, bw = 240; + const bh = box.offsetHeight || 118; let left = r.left; let top = r.bottom + mg; if (left + bw > window.innerWidth - mg) left = window.innerWidth - bw - mg; if (left < mg) left = mg; if (top + bh > window.innerHeight - mg) top = r.top - bh - mg; if (top < mg) top = mg; + box.style.left = `${left}px`; + box.style.top = `${top}px`; +} + +// Grows the textarea with its content up to a max height, then lets it scroll +// internally (harmless now that the window scroll listener no longer closes the +// compose box on incidental scrolls originating inside loupe's own UI). +function autoGrowTextarea(ta: HTMLTextAreaElement): void { + ta.style.height = `${TEXTAREA_MIN_HEIGHT}px`; + const next = Math.max(Math.min(ta.scrollHeight, TEXTAREA_MAX_HEIGHT), TEXTAREA_MIN_HEIGHT); + ta.style.height = `${next}px`; + ta.style.overflowY = ta.scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; +} + +function openCompose(el: Element, selector: string, restore?: ComposeRestoreOptions): void { + if (composeBox) { composeBox.remove(); composeBox = null; } + cancelPendingFocusFlip?.(); + const contentSnippet = getContentSnippet(el); const box = document.createElement('div'); box.setAttribute('data-loupe-compose', '1'); - box.style.cssText = `position:fixed;z-index:2147483001;background:#1e293b;border:1.5px solid #3b82f6;border-radius:8px;padding:8px;width:240px;box-shadow:0 4px 20px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:5px;left:${left}px;top:${top}px;`; + box.style.cssText = 'position:fixed;z-index:2147483001;background:#1e293b;border:1.5px solid #3b82f6;border-radius:8px;padding:8px;width:240px;box-shadow:0 4px 20px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:5px;left:-9999px;top:-9999px;'; const lbl = document.createElement('div'); lbl.style.cssText = 'font-size:10px;color:#94a3b8;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; @@ -197,7 +257,7 @@ function openCompose(el: Element, selector: string): void { box.appendChild(lbl); const ta = document.createElement('textarea'); - ta.style.cssText = 'background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:6px;font-size:12px;resize:none;height:56px;outline:none;font-family:system-ui,sans-serif;width:100%;box-sizing:border-box;line-height:1.4;'; + ta.style.cssText = `background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:4px;padding:6px;font-size:12px;resize:none;height:${TEXTAREA_MIN_HEIGHT}px;overflow-y:hidden;outline:none;font-family:system-ui,sans-serif;width:100%;box-sizing:border-box;line-height:1.4;`; ta.placeholder = 'Type note, Enter to add...'; box.appendChild(ta); @@ -206,6 +266,61 @@ function openCompose(el: Element, selector: string): void { hint.textContent = 'Enter: add Shift+Enter: newline Cmd+Enter: add & send all Esc: cancel'; box.appendChild(hint); + // Whether the textarea is focused, tracked explicitly instead of read from + // document.activeElement at persist time. By the time any blur handler (or the + // pagehide handler below) runs, activeElement has already moved on to whatever + // took focus (or to document.body), so reading it there would always report + // "not focused", even when the blur is really just an artifact of the iframe + // document being torn down for reload rather than a deliberate focus change. + let isFocused = restore ? restore.focus : true; + let focusFlipTimer: number | null = null; + + function cancelFocusFlip(): void { + if (focusFlipTimer !== null) { window.clearTimeout(focusFlipTimer); focusFlipTimer = null; } + } + + function persistDraft(): void { + try { + const draft: PersistedDraft = { + selector, + contentSnippet, + text: ta.value, + selectionStart: ta.selectionStart, + selectionEnd: ta.selectionEnd, + focused: isFocused, + }; + sessionStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); + } catch (_e) {} + } + + ta.addEventListener('input', () => { + autoGrowTextarea(ta); + positionComposeBox(el, box); + persistDraft(); + }); + ta.addEventListener('keyup', persistDraft); + ta.addEventListener('click', persistDraft); + ta.addEventListener('focus', () => { + cancelFocusFlip(); + isFocused = true; + persistDraft(); + }); + ta.addEventListener('blur', () => { + // Defer the flip to false by one macrotask rather than applying it immediately. + // A genuine blur (e.g. focus moving to the parent shell's own sidebar input) has + // nothing racing it, so the flip lands a moment later as normal. A blur that is + // just a side effect of this document unloading for reload is followed by + // pagehide first, which cancels this timer, so isFocused (and the persisted + // draft) still says "focused" when we come back. Ambiguous cases resolve in + // favor of restoring focus, since losing it silently is the worse outcome. + cancelFocusFlip(); + focusFlipTimer = window.setTimeout(() => { + focusFlipTimer = null; + isFocused = false; + persistDraft(); + }, 0); + }); + ta.addEventListener('keydown', (e: KeyboardEvent) => { e.stopPropagation(); if (e.key === 'Enter' && e.shiftKey) return; @@ -229,13 +344,38 @@ function openCompose(el: Element, selector: string): void { document.body.appendChild(box); composeBox = box; - ta.focus(); + persistCurrentDraft = persistDraft; + cancelPendingFocusFlip = cancelFocusFlip; + + // Settle the height first (it affects the top/bottom flip math), then place the + // box at its final position, and only then focus anything. Focusing an element + // still parked at its off-screen placeholder position risks the browser trying to + // scroll it into view, which would visibly jump the page (and, on restore, fire a + // spurious scroll event during init). + if (restore) { + ta.value = restore.text; + autoGrowTextarea(ta); + } + positionComposeBox(el, box); + + if (restore) { + const len = ta.value.length; + try { ta.setSelectionRange(Math.min(restore.selectionStart, len), Math.min(restore.selectionEnd, len)); } catch (_e) {} + if (restore.focus) ta.focus(); + } else { + ta.focus(); + } + persistDraft(); } function closeCompose(): void { if (composeBox) { composeBox.remove(); composeBox = null; } if (selOv) selOv.style.display = 'none'; selectedEl = null; + cancelPendingFocusFlip?.(); + cancelPendingFocusFlip = null; + persistCurrentDraft = null; + clearPersistedDraft(); } /* ---- rAF tick ---- */ @@ -246,6 +386,9 @@ function tick(): void { if (selectedEl && selOv && selOv.style.display !== 'none') { placeOv(selOv, selectedEl); } + if (composeBox && selectedEl) { + positionComposeBox(selectedEl, composeBox); + } requestAnimationFrame(tick); } requestAnimationFrame(tick); @@ -276,8 +419,56 @@ function checkAnchor(threadId: string, selector: string | null, contentSnippet: return { threadId, selector: null, orphaned: true }; } +/* ---- draft restoration across iframe reloads ---- */ +// Reopens an in-progress compose box after the shell reloads the artifact iframe +// (e.g. the agent just edited the file on disk). Reuses checkAnchor, the same +// stale-selector-to-element machinery used for orphan detection elsewhere, so a +// draft survives even if the reload changed the DOM enough to shift the selector. +function restoreDraftOnLoad(): void { + let raw: string | null = null; + try { raw = sessionStorage.getItem(DRAFT_KEY); } catch (_e) { return; } + if (!raw) return; + + let draft: PersistedDraft; + try { draft = JSON.parse(raw) as PersistedDraft; } catch (_e) { clearPersistedDraft(); return; } + + const anchor = checkAnchor('__draft__', draft.selector || null, draft.contentSnippet || null); + if (anchor.orphaned || !anchor.selector) { clearPersistedDraft(); return; } + + const target = document.querySelector(anchor.selector); + if (!target) { clearPersistedDraft(); return; } + + selectedEl = target; + showSelOv(target); + openCompose(target, anchor.selector, { + text: draft.text, + selectionStart: draft.selectionStart, + selectionEnd: draft.selectionEnd, + focus: draft.focused, + }); +} +restoreDraftOnLoad(); + +// Final flush before the iframe document is torn down, e.g. by the shell reloading +// the artifact after the agent edits the file on disk. Cancel any pending "focus was +// lost" flip first, so a blur that only fired as a side effect of this same teardown +// does not get to record "not focused" before we capture the final draft state. +window.addEventListener('pagehide', () => { + cancelPendingFocusFlip?.(); + persistCurrentDraft?.(); +}); + /* ---- postMessage from parent shell ---- */ -window.addEventListener('scroll', () => { if (composeBox) closeCompose(); }, true); +// Scroll events do not bubble, but this listener is capture-phase on window, so it +// still fires for scrolls inside descendants, including the compose textarea's own +// internal overflow scrolling once a note grows past its visible height. Ignore those +// entirely. A real page scroll no longer closes the compose box either: it stays +// anchored to its target element every frame via tick(), closing on scroll just to +// lose the user's draft is itself the bug we're fixing. +window.addEventListener('scroll', (e: Event) => { + if (isLoupeEl(e.target)) return; + if (composeBox && selectedEl) positionComposeBox(selectedEl, composeBox); +}, true); window.addEventListener('message', (e: MessageEvent) => { if (!e.data || typeof e.data !== 'object') return;