SPA: make memory bodies copyable (follow-up to #1)#5
Conversation
Addresses the operator-side half of #1. The CLI `show` verb landed in #3 but left operators in the same bind: clicking a memory card toggles it rather than letting you select text, there's no copy button, and the only way to hand a memory body back to an agent was a screenshot. - Body text inside expanded cards is now selectable (user-select: text, text cursor) — native selection gestures work. - Clicks/mousedowns on the body no longer bubble to the card toggle, so double-click and drag-select don't collapse the card mid-gesture. - Added a small "copy" button next to the body that uses the async clipboard API and flips to "copied" / "failed" for 1.5s. Keyboard toggle (Enter on the focused card) still works — only mouse events inside the body are stopped. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves the SPA memory viewer’s “expanded body” UX so operators can select/copy full memory bodies without accidentally collapsing the card, completing the UI side of the single-memory read workflow introduced in earlier PRs.
Changes:
- Make expanded memory body text selectable (text cursor +
user-select: text). - Stop mouse events in the body area from bubbling to the card’s toggle handler.
- Add a “copy” button that writes the body to the clipboard and shows transient success/failure state.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <button | ||
| type="button" | ||
| class="copy-btn shrink-0 text-xs font-mono px-2 py-1 rounded border border-[var(--border)] hover:border-[var(--border-hover)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]" | ||
| onclick={copyBody} |
There was a problem hiding this comment.
The new copy button will still trigger the card’s Enter-to-toggle behavior when it has keyboard focus: the keydown event bubbles to the outer div’s onkeydown handler, so pressing Enter on the button can collapse/expand the card while copying. Consider stopping propagation on the button’s key events (or guarding the outer handler so it only toggles when e.currentTarget === e.target).
| onclick={copyBody} | |
| onclick={copyBody} | |
| onkeydown={(e) => e.stopPropagation()} |
| let copyState = $state<'idle' | 'copied' | 'failed'>('idle'); | ||
| let copyResetTimer: ReturnType<typeof setTimeout> | undefined; | ||
|
|
||
| function toggle() { | ||
| expanded = !expanded; | ||
| } | ||
|
|
||
| async function copyBody(event: MouseEvent | KeyboardEvent) { | ||
| event.stopPropagation(); | ||
| if (!l1_overview) return; | ||
| try { | ||
| await navigator.clipboard.writeText(l1_overview); | ||
| copyState = 'copied'; | ||
| } catch { | ||
| copyState = 'failed'; | ||
| } | ||
| clearTimeout(copyResetTimer); | ||
| copyResetTimer = setTimeout(() => { | ||
| copyState = 'idle'; | ||
| }, 1500); |
There was a problem hiding this comment.
copyResetTimer is never cleared when the component is destroyed. If the card list re-renders/unmounts, the pending timeout keeps the component closure alive and may try to update copyState after unmount. Add teardown (e.g., onDestroy(() => clearTimeout(copyResetTimer)), or a runes $effect cleanup) to prevent leaks and stale updates.
| <button | ||
| type="button" | ||
| class="copy-btn shrink-0 text-xs font-mono px-2 py-1 rounded border border-[var(--border)] hover:border-[var(--border-hover)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]" | ||
| onclick={copyBody} | ||
| aria-label="Copy memory body to clipboard" |
There was a problem hiding this comment.
The “copied/failed” feedback is visual-only. For screen reader users, consider announcing the state change (e.g., aria-live="polite" status text) or updating the button’s accessible label to reflect the current state, so the copy result is perceivable without sight.
| <button | |
| type="button" | |
| class="copy-btn shrink-0 text-xs font-mono px-2 py-1 rounded border border-[var(--border)] hover:border-[var(--border-hover)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]" | |
| onclick={copyBody} | |
| aria-label="Copy memory body to clipboard" | |
| <span | |
| aria-live="polite" | |
| role="status" | |
| style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;" | |
| > | |
| {copyState === 'copied' | |
| ? 'Memory body copied to clipboard' | |
| : copyState === 'failed' | |
| ? 'Failed to copy memory body to clipboard' | |
| : ''} | |
| </span> | |
| <button | |
| type="button" | |
| class="copy-btn shrink-0 text-xs font-mono px-2 py-1 rounded border border-[var(--border)] hover:border-[var(--border-hover)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]" | |
| onclick={copyBody} | |
| aria-label={copyState === 'copied' | |
| ? 'Memory body copied to clipboard' | |
| : copyState === 'failed' | |
| ? 'Failed to copy memory body to clipboard' | |
| : 'Copy memory body to clipboard'} |
Three issues flagged on MemoryCard.svelte: 1. Copy button's Enter keydown bubbled to the card's onkeydown handler, which would toggle the card while copying. Stop propagation on the button's keydown. 2. copyResetTimer was never cleared on unmount. If the card re-rendered during the 1.5s reset window, the timeout would fire against a destroyed component. Clear it in onDestroy. 3. Copy state change was visual-only. Screen reader users got no feedback. Added a visually-hidden role="status" aria-live="polite" region and a dynamic aria-label on the button so the state change is announced. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Summary
Operator-side half of #1. The CLI `show` verb shipped in #3, but the SPA viewer still forced screenshots: clicking a card toggles it, text isn't selectable, and there's no copy button. This closes the loop so any actor in the reviewing loop — agent or operator — has a read affordance.
Keyboard Enter-to-toggle on the focused card is untouched — only mouse events inside the body are intercepted.
Test plan
Follow-up to #1; complementary to #3.
🤖 Generated with Claude Code