Skip to content

SPA: make memory bodies copyable (follow-up to #1)#5

Merged
lazypower merged 2 commits intomainfrom
fix/ui-memory-copy-affordance
Apr 24, 2026
Merged

SPA: make memory bodies copyable (follow-up to #1)#5
lazypower merged 2 commits intomainfrom
fix/ui-memory-copy-affordance

Conversation

@lazypower
Copy link
Copy Markdown
Owner

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.

  • Body text inside expanded cards is now selectable (`user-select: text`, text cursor).
  • Mouse events on the body are stopped from bubbling to the card toggle, so drag-select and double-click don't collapse the card mid-gesture.
  • Small "copy" button next to the body uses the async clipboard API. Flips to "copied" / "failed" for 1.5s.

Keyboard Enter-to-toggle on the focused card is untouched — only mouse events inside the body are intercepted.

Test plan

  • `go test ./...` passes (no code paths changed; guard against accidental regression).
  • `npm run build` in `ui/` is clean.
  • Full `make build` pipeline produces a binary with the updated UI embedded.
  • Manual: expand a card, drag-select a paragraph, confirm it stays expanded.
  • Manual: click the copy button, confirm "copied" flash and paste contains the full L1 body.
  • Manual: Tab to a card, press Enter — still toggles expanded.

Follow-up to #1; complementary to #3.

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
onclick={copyBody}
onclick={copyBody}
onkeydown={(e) => e.stopPropagation()}

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +35
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);
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread ui/src/components/MemoryCard.svelte Outdated
Comment on lines +132 to +136
<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"
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<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'}

Copilot uses AI. Check for mistakes.
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>
@lazypower lazypower merged commit b82fdd8 into main Apr 24, 2026
2 checks passed
@lazypower lazypower deleted the fix/ui-memory-copy-affordance branch April 24, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants