From 12c28bdaff6269887e4e32722f7c235254a6394b Mon Sep 17 00:00:00 2001 From: Ayush Jhunjhunwala <48875674+the-non-expert@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:02:56 +0530 Subject: [PATCH 1/4] adding modular copy options for all the md to rich text viewer + timeline --- api/models/jsonl_utils.py | 84 +++++- api/tests/test_agent.py | 12 +- api/tests/test_jsonl_utils.py | 3 +- api/tests/test_session.py | 3 + frontend/src/app.css | 104 ++++++++ .../src/lib/actions/markdownCopyButtons.ts | 251 ++++++++++++++++++ .../lib/components/ExpandablePrompt.svelte | 3 + .../lib/components/agents/AgentViewer.svelte | 3 +- .../components/commands/CommandsPanel.svelte | 3 +- .../lib/components/memory/MemoryViewer.svelte | 3 +- .../src/lib/components/plan/PlanViewer.svelte | 5 +- .../lib/components/skills/SkillViewer.svelte | 3 +- .../timeline/TimelineEventCard.svelte | 33 ++- .../components/timeline/TimelineRail.svelte | 12 +- .../components/timeline/ToolCallDetail.svelte | 73 ++--- .../src/routes/agents/[name]/+page.svelte | 3 +- .../commands/[command_name]/+page.svelte | 3 +- .../[plugin_id]/skills/[...path]/+page.svelte | 2 + .../src/routes/skills/[...path]/+page.svelte | 3 +- .../routes/skills/[skill_name]/+page.svelte | 3 +- 20 files changed, 550 insertions(+), 59 deletions(-) create mode 100644 frontend/src/lib/actions/markdownCopyButtons.ts diff --git a/api/models/jsonl_utils.py b/api/models/jsonl_utils.py index 8b6fe1ee..745b060c 100644 --- a/api/models/jsonl_utils.py +++ b/api/models/jsonl_utils.py @@ -8,6 +8,7 @@ from __future__ import annotations +import copy import json from pathlib import Path from typing import Iterator @@ -15,6 +16,52 @@ from .message import Message, parse_message +def _merge_user_message_dicts(base: dict, extra: dict) -> dict: + """ + Merge two raw user message dicts that share the same timestamp. + + Claude Code emits a pair of user messages at the same timestamp when + an image is attached: the first contains the real text + base64 image + block, and the second is a text-only fallback with a file-path reference + like ``[Image: source: /var/folders/...]``. We merge both into one dict + so the downstream parser sees a single message with the correct content + and image attachment. + + The file-path reference parts are dropped because the image data is + already present in the base message's image content block. Any other + real text in the extra message is preserved. + """ + merged = copy.deepcopy(base) + + def _get_content(d: dict) -> list: + c = d.get("message", {}).get("content") or d.get("content", []) + return c if isinstance(c, list) else [] + + base_content = _get_content(merged) + extra_content = _get_content(extra) + + # Keep extra parts that are not redundant [Image: source: ...] references + real_extra = [ + part + for part in extra_content + if not ( + isinstance(part, dict) + and part.get("type") == "text" + and isinstance(part.get("text", ""), str) + and part["text"].startswith("[Image: source:") + ) + ] + + if real_extra: + combined = base_content + real_extra + if "message" in merged: + merged["message"] = {**merged["message"], "content": combined} + else: + merged["content"] = combined + + return merged + + def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]: """ Iterate over messages in a JSONL file. @@ -23,6 +70,11 @@ def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]: parsed Message instances. Handles missing files, empty lines, and malformed JSON gracefully. + Consecutive user messages that share an identical timestamp are merged + into a single message before parsing. Claude Code writes such pairs + when the user attaches an image: one entry with the real text + base64 + image block and a second text-only entry with a file-path reference. + Args: jsonl_path: Path to the JSONL file containing messages. @@ -38,6 +90,8 @@ def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]: if not jsonl_path.exists(): return + pending: dict | None = None + with open(jsonl_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() @@ -45,7 +99,31 @@ def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]: continue try: data = json.loads(line) - yield parse_message(data) - except (json.JSONDecodeError, ValueError, KeyError): - # Skip malformed lines + except json.JSONDecodeError: continue + + # Merge consecutive user messages with the same timestamp into one + if ( + pending is not None + and pending.get("type") == "user" + and data.get("type") == "user" + and pending.get("timestamp") == data.get("timestamp") + ): + pending = _merge_user_message_dicts(pending, data) + continue + + # Yield the previously buffered message + if pending is not None: + try: + yield parse_message(pending) + except (ValueError, KeyError): + pass + + pending = data + + # Yield the final buffered message + if pending is not None: + try: + yield parse_message(pending) + except (ValueError, KeyError): + pass diff --git a/api/tests/test_agent.py b/api/tests/test_agent.py index 121c5d52..c2fa8e88 100644 --- a/api/tests/test_agent.py +++ b/api/tests/test_agent.py @@ -360,16 +360,22 @@ def test_message_count_skips_empty_lines(self, temp_project_dir: Path) -> None: agent_path = temp_project_dir / "agent-with-empty.jsonl" with open(agent_path, "w") as f: - msg = { + msg1 = { "type": "user", "message": {"role": "user", "content": "test"}, "uuid": "uuid-1", "timestamp": "2026-01-08T13:00:00.000Z", } - f.write(json.dumps(msg) + "\n") + msg2 = { + "type": "user", + "message": {"role": "user", "content": "test2"}, + "uuid": "uuid-2", + "timestamp": "2026-01-08T13:01:00.000Z", + } + f.write(json.dumps(msg1) + "\n") f.write("\n") # Empty line f.write(" \n") # Whitespace only line - f.write(json.dumps(msg) + "\n") + f.write(json.dumps(msg2) + "\n") agent = Agent.from_path(agent_path) diff --git a/api/tests/test_jsonl_utils.py b/api/tests/test_jsonl_utils.py index d1d0f5a7..75324f15 100644 --- a/api/tests/test_jsonl_utils.py +++ b/api/tests/test_jsonl_utils.py @@ -108,7 +108,8 @@ def test_preserves_message_order( for i in range(5): msg = sample_user_message_data.copy() msg["uuid"] = f"uuid-{i}" - msg["message"]["content"] = f"message {i}" + msg["timestamp"] = f"2026-01-08T13:0{i}:00.000Z" + msg["message"] = {"role": "user", "content": f"message {i}"} f.write(json.dumps(msg) + "\n") messages = list(iter_messages_from_jsonl(jsonl_path)) diff --git a/api/tests/test_session.py b/api/tests/test_session.py index 551a6c8a..60968ad6 100644 --- a/api/tests/test_session.py +++ b/api/tests/test_session.py @@ -777,10 +777,12 @@ def test_get_git_branches_multiple( msg2 = sample_user_message_data.copy() msg2["uuid"] = "user-msg-002" + msg2["timestamp"] = "2026-01-08T13:01:00.000Z" msg2["gitBranch"] = "feature/new-stuff" msg3 = sample_user_message_data.copy() msg3["uuid"] = "user-msg-003" + msg3["timestamp"] = "2026-01-08T13:02:00.000Z" msg3["gitBranch"] = "main" # Duplicate with open(jsonl_path, "w") as f: @@ -827,6 +829,7 @@ def test_get_working_directories_multiple( msg2 = sample_user_message_data.copy() msg2["uuid"] = "user-msg-002" + msg2["timestamp"] = "2026-01-08T13:01:00.000Z" msg2["cwd"] = "/Users/test/project2" with open(jsonl_path, "w") as f: diff --git a/frontend/src/app.css b/frontend/src/app.css index 79a27332..3e1b91e0 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -643,6 +643,94 @@ body { @apply mt-1 mb-0; } +/* ── Markdown inline copy buttons (injected by markdownCopyButtons action) ── */ + +/* pre needs relative positioning for the absolute code-copy button */ +.markdown-preview pre { + position: relative; +} + +.md-copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border-radius: 0.375rem; + border: 1px solid var(--border); + background: var(--bg-base); + color: var(--text-muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease; + flex-shrink: 0; + line-height: 1; +} + +.md-copy-btn:hover { + color: var(--text-primary); + border-color: var(--accent); + opacity: 1; +} + +.md-copy-btn--copied { + color: var(--success) !important; + opacity: 1 !important; +} + +/* Code block button: always visible (no hover required) */ +.md-copy-btn--code { + position: absolute; + top: 0.5rem; + right: 0.5rem; + opacity: 1; +} + +/* Section button: inline after heading text, revealed on parent hover */ +.md-copy-btn--section { + vertical-align: middle; + margin-left: 0.5rem; + transform: translateY(-1px); +} + +/* All section copy buttons — always visible */ +.md-copy-btn--section { + opacity: 1; +} + + +/* ── Custom tooltip for the global "copy entire response" button ── */ +/* Uses data-tooltip attribute + ::before pseudo-element so it appears + instantly on hover without the OS-imposed delay of the native title attr. */ + +.md-global-copy { + position: relative; +} + +.md-global-copy::before { + content: attr(data-tooltip); + position: absolute; + right: calc(100% + 8px); + top: 50%; + transform: translateY(-50%); + background: var(--bg-muted); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 3px 8px; + font-size: 11px; + font-family: inherit; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.1s ease; + z-index: 50; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); +} + +.md-global-copy:hover::before { + opacity: 1; +} + /* ============================================ VIM-STYLE LIST NAVIGATION ============================================ */ @@ -807,6 +895,22 @@ body { } } +/* Session last-opened highlight ring — fades out after mount */ +@keyframes session-highlight-fade { + 0% { box-shadow: 0 0 0 2px var(--accent); } + 60% { box-shadow: 0 0 0 2px var(--accent); } + 100% { box-shadow: 0 0 0 2px transparent; } +} +.session-highlight { + animation: session-highlight-fade 1.8s ease-out forwards; +} +@media (prefers-reduced-motion: reduce) { + .session-highlight { + animation: none; + box-shadow: 0 0 0 2px var(--accent); + } +} + /* Search highlighting */ .search-highlight { background: color-mix(in srgb, var(--warning) 30%, transparent); diff --git a/frontend/src/lib/actions/markdownCopyButtons.ts b/frontend/src/lib/actions/markdownCopyButtons.ts new file mode 100644 index 00000000..a5505bf5 --- /dev/null +++ b/frontend/src/lib/actions/markdownCopyButtons.ts @@ -0,0 +1,251 @@ +/** + * Svelte action that injects per-section and per-code-block copy buttons + * into a `.markdown-preview` container after `{@html}` has populated the DOM. + * + * Uses a MutationObserver to detect when {@html} changes the DOM — more + * reliable than queueMicrotask because it fires AFTER the DOM mutation, + * regardless of Svelte's internal flush ordering. + * + * Section detection covers two patterns Claude actually uses: + * 1. Proper markdown headings —

and

+ * 2. Bold-only paragraphs —

Title

preceded by
+ * (Claude's **Title** + --- style) + * + * Buttons are only injected when a section's content meets MIN_SECTION_CHARS. + * This is heading-level agnostic: a meaty h3 gets a button, a one-liner h2 + * does not. Code block copy buttons are always injected regardless of length. + * + * h2 sections include all h3 content beneath them in their character count, + * so a document with one h2 title + several h3 steps will correctly show + * a button on the h2 (whole plan) AND on each substantive h3 step. + */ + +/** Minimum plain-text characters a section must contain to earn a copy button. */ +const MIN_SECTION_CHARS = 150; + +const COPY_ICON = ``; +const CHECK_ICON = ``; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeCopyButton(extraClass: string, label: string): HTMLButtonElement { + const btn = document.createElement('button'); + btn.className = `md-copy-btn ${extraClass}`; + btn.setAttribute('aria-label', label); + // No title attribute — aria-label handles accessibility and avoids the + // native browser tooltip (which has an OS-imposed delay and can't be styled). + btn.innerHTML = COPY_ICON; + btn.type = 'button'; + return btn; +} + +function flashCopied(btn: HTMLButtonElement): ReturnType { + btn.innerHTML = CHECK_ICON; + btn.classList.add('md-copy-btn--copied'); + return setTimeout(() => { + btn.innerHTML = COPY_ICON; + btn.classList.remove('md-copy-btn--copied'); + }, 2000); +} + +/** + * Returns true if the element is a paragraph whose only non-whitespace child + * is a single — Claude's **bold heading** pattern. + */ +function isBoldHeading(el: Element): boolean { + if (el.tagName !== 'P') return false; + const meaningful = Array.from(el.childNodes).filter( + (n) => !(n.nodeType === Node.TEXT_NODE && n.textContent?.trim() === '') + ); + return ( + meaningful.length === 1 && + meaningful[0].nodeType === Node.ELEMENT_NODE && + (meaningful[0] as Element).tagName === 'STRONG' + ); +} + +/** + * What heading level stops the current section? + * + * h2 section → stops at next h2, HR, or top-level bold paragraph + * h3 section → stops at next h2, h3, HR, or top-level bold paragraph + * bold-para → stops at next HR, h2, or top-level bold paragraph + */ +function isSectionBoundary(el: Element, startTag: string): boolean { + const tag = el.tagName; + if (tag === 'HR' || tag === 'H2') return true; + if (startTag === 'H3' && tag === 'H3') return true; + // A bold paragraph that directly follows an HR is a top-level section title + if (isBoldHeading(el)) { + const prev = el.previousElementSibling; + if (prev === null || prev.tagName === 'HR') return true; + } + return false; +} + +/** + * Collect all plain text from `startEl` through siblings until the next + * section boundary. Returns { text, charCount }. + * + * The heading/title text itself is included so copy output is self-contained. + * charCount measures only the *prose* body content (siblings after the heading, + * excluding
 code blocks) so that a heading whose body is entirely code
+ * blocks — which already have their own copy buttons — doesn't pass the
+ * threshold. The full text for copying still includes code block content.
+ */
+function collectSection(startEl: Element): { text: string; charCount: number } {
+	const startTag = startEl.tagName; // H2, H3, or P
+	const titleText = (startEl.textContent ?? '').trim();
+
+	const bodyParts: string[] = [];
+	let proseCharCount = 0;
+	let sibling = startEl.nextElementSibling;
+
+	while (sibling) {
+		if (isSectionBoundary(sibling, startTag)) break;
+		const t = (sibling.textContent ?? '').trim();
+		if (t) {
+			bodyParts.push(t);
+			// Only count prose chars towards threshold — exclude 
 blocks
+			// since those already have their own copy buttons
+			if (sibling.tagName !== 'PRE') {
+				proseCharCount += t.length;
+			}
+		}
+		sibling = sibling.nextElementSibling;
+	}
+
+	const text = [titleText, ...bodyParts].filter(Boolean).join('\n\n');
+	return { text, charCount: proseCharCount };
+}
+
+// ─── Action ──────────────────────────────────────────────────────────────────
+
+export function markdownCopyButtons(node: HTMLElement, _content?: string) {
+	const cleanupFns: Array<() => void> = [];
+	let setupPending = false;
+
+	function attachButton(
+		anchorEl: HTMLElement,
+		extraClass: string,
+		label: string,
+		getText: () => string
+	) {
+		const btn = makeCopyButton(extraClass, label);
+		let timer: ReturnType;
+
+		const onClick = (e: Event) => {
+			e.stopPropagation();
+			navigator.clipboard.writeText(getText()).then(() => {
+				clearTimeout(timer);
+				timer = flashCopied(btn);
+			});
+		};
+
+		btn.addEventListener('click', onClick);
+		anchorEl.appendChild(btn);
+
+		cleanupFns.push(() => {
+			btn.removeEventListener('click', onClick);
+			clearTimeout(timer);
+			btn.remove();
+		});
+	}
+
+	function setup() {
+		// Remove any previously injected buttons and reset cleanup list
+		node.querySelectorAll('.md-copy-btn').forEach((el) => el.remove());
+		cleanupFns.length = 0;
+
+		// ── Code blocks — always injected, no length gate ────────────────────
+		// Code is the most-copied thing; length is irrelevant.
+		node.querySelectorAll('pre').forEach((pre) => {
+			attachButton(pre, 'md-copy-btn--code', 'Copy code', () => {
+				const code = pre.querySelector('code');
+				return (code ?? pre).textContent ?? '';
+			});
+		});
+
+		// ── Headings (h2 and h3) — gated by MIN_SECTION_CHARS ────────────────
+		//
+		// h2 sections include all h3 content beneath them in their char count
+		// (collectSection walks past h3 boundaries for h2 starts).
+		// Each h3 is independently evaluated on its own sub-content only.
+		//
+		// Visual style distinction:
+		//   h2 → always-visible button (major section)
+		//   h3 → hover-reveal button (subsection, less prominent)
+		node.querySelectorAll('h2, h3').forEach((heading) => {
+			const { text, charCount } = collectSection(heading);
+			if (charCount < MIN_SECTION_CHARS) return;
+
+			const extraClass = 'md-copy-btn--section';
+
+			attachButton(heading, extraClass, 'Copy section', () => text);
+		});
+
+		// ── Bold-paragraph headings (Claude's **Title** + --- style) ─────────
+		//
+		// Only treat as a section title when immediately preceded by 
or + // at the very start — this excludes mid-section sub-labels like + // "Connection distances:" which appear inside another section's body. + node.querySelectorAll('p').forEach((p) => { + if (!isBoldHeading(p)) return; + + const prev = p.previousElementSibling; + const isTopLevel = prev === null || prev.tagName === 'HR'; + if (!isTopLevel) return; + + const { text, charCount } = collectSection(p); + if (charCount < MIN_SECTION_CHARS) return; + + // Append inside so the button sits right after the title text + const anchor = (p.querySelector('strong') ?? p) as HTMLElement; + attachButton(anchor, 'md-copy-btn--section', 'Copy section', () => text); + }); + + } + + // ── MutationObserver ───────────────────────────────────────────────────── + // + // subtree: false — only watch direct children of the markdown-preview node. + // {@html} replaces direct children, so this catches real content changes. + // subtree: true would also fire on our btn.innerHTML swaps (copy→check icon), + // causing setup() to wipe the check icon immediately after clicking. + const observer = new MutationObserver((mutations) => { + if (setupPending) return; + + // Ignore mutations that are entirely from our own injected buttons + const hasRealContentChange = mutations.some((m) => + Array.from(m.addedNodes).some( + (n) => !(n instanceof Element && n.classList.contains('md-copy-btn')) + ) + ); + + if (hasRealContentChange) { + setupPending = true; + setTimeout(() => { + setup(); + setupPending = false; + }, 0); + } + }); + + observer.observe(node, { childList: true, subtree: false }); + + // Run immediately in case content is already present on mount + if (node.children.length > 0) { + setup(); + } + + return { + update(_newContent?: string) { + // MutationObserver handles re-scanning — no-op kept for Svelte API compat. + }, + destroy() { + observer.disconnect(); + node.querySelectorAll('.md-copy-btn').forEach((el) => el.remove()); + cleanupFns.length = 0; + } + }; +} diff --git a/frontend/src/lib/components/ExpandablePrompt.svelte b/frontend/src/lib/components/ExpandablePrompt.svelte index 3b87ab07..e4a4ce3c 100644 --- a/frontend/src/lib/components/ExpandablePrompt.svelte +++ b/frontend/src/lib/components/ExpandablePrompt.svelte @@ -10,6 +10,7 @@ Check, Maximize2 } from 'lucide-svelte'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import Modal from '$lib/components/ui/Modal.svelte'; import ImageAttachments from '$lib/components/ImageAttachments.svelte'; import type { ImageAttachment } from '$lib/api-types'; @@ -222,6 +223,7 @@ class="markdown-preview text-sm prompt-content {!isExpanded && needsExpansion ? 'prompt-preview' : ''}" + use:markdownCopyButtons={renderedContent} > {@html renderedContent} @@ -288,6 +290,7 @@ {/if}
{@html renderedContent}
diff --git a/frontend/src/lib/components/agents/AgentViewer.svelte b/frontend/src/lib/components/agents/AgentViewer.svelte index a4b79082..cad0b4bf 100644 --- a/frontend/src/lib/components/agents/AgentViewer.svelte +++ b/frontend/src/lib/components/agents/AgentViewer.svelte @@ -3,6 +3,7 @@ import { Bot, Loader2, Check, Eye, Code, Clock, HardDrive, Copy, Layers } from 'lucide-svelte'; import { marked } from 'marked'; import DOMPurify from 'isomorphic-dompurify'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import { formatDistanceToNow } from 'date-fns'; import { API_BASE } from '$lib/config'; import { formatFileSize } from '$lib/utils'; @@ -186,7 +187,7 @@ >Preview -
+
{@html renderedContent}
{/if} diff --git a/frontend/src/lib/components/commands/CommandsPanel.svelte b/frontend/src/lib/components/commands/CommandsPanel.svelte index d7489f9f..5b7ce8f5 100644 --- a/frontend/src/lib/components/commands/CommandsPanel.svelte +++ b/frontend/src/lib/components/commands/CommandsPanel.svelte @@ -7,6 +7,7 @@ import Modal from '$lib/components/ui/Modal.svelte'; import { cleanSkillName, getCommandColorVars, getCommandCategoryColorVars, getCommandCategoryLabel } from '$lib/utils'; import { API_BASE } from '$lib/config'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; interface Props { commands: CommandUsage[]; @@ -241,7 +242,7 @@ {modalError}
{:else} -
+
{@html renderedContent}
{/if} diff --git a/frontend/src/lib/components/memory/MemoryViewer.svelte b/frontend/src/lib/components/memory/MemoryViewer.svelte index 60ca3c3b..fba01e6f 100644 --- a/frontend/src/lib/components/memory/MemoryViewer.svelte +++ b/frontend/src/lib/components/memory/MemoryViewer.svelte @@ -3,6 +3,7 @@ import DOMPurify from 'isomorphic-dompurify'; import { formatDistanceToNow } from 'date-fns'; import { Brain, BookOpen, Terminal, Loader2 } from 'lucide-svelte'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import { API_BASE } from '$lib/config'; import type { ProjectMemory } from '$lib/api-types'; import Card from '$lib/components/ui/Card.svelte'; @@ -150,7 +151,7 @@ -
+
{@html renderedContent}
diff --git a/frontend/src/lib/components/plan/PlanViewer.svelte b/frontend/src/lib/components/plan/PlanViewer.svelte index 6dcd8e7f..224e9281 100644 --- a/frontend/src/lib/components/plan/PlanViewer.svelte +++ b/frontend/src/lib/components/plan/PlanViewer.svelte @@ -5,6 +5,7 @@ import { FileText } from 'lucide-svelte'; import type { PlanDetail } from '$lib/api-types'; import Card from '$lib/components/ui/Card.svelte'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; interface Props { plan: PlanDetail; @@ -61,7 +62,7 @@ {#if embedded} -
+
{@html renderedContent}
{:else} @@ -85,7 +86,7 @@
-
+
{@html renderedContent}
diff --git a/frontend/src/lib/components/skills/SkillViewer.svelte b/frontend/src/lib/components/skills/SkillViewer.svelte index 407de514..fee8197a 100644 --- a/frontend/src/lib/components/skills/SkillViewer.svelte +++ b/frontend/src/lib/components/skills/SkillViewer.svelte @@ -3,6 +3,7 @@ import { FileText, Loader2, Copy, Check, Eye, Code, Clock, HardDrive } from 'lucide-svelte'; import { marked } from 'marked'; import DOMPurify from 'isomorphic-dompurify'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import { formatDistanceToNow } from 'date-fns'; import { API_BASE } from '$lib/config'; import { formatFileSize } from '$lib/utils'; @@ -177,7 +178,7 @@ >Preview
-
+
{@html renderedContent}
{/if} diff --git a/frontend/src/lib/components/timeline/TimelineEventCard.svelte b/frontend/src/lib/components/timeline/TimelineEventCard.svelte index ecc564bb..1100b2a9 100644 --- a/frontend/src/lib/components/timeline/TimelineEventCard.svelte +++ b/frontend/src/lib/components/timeline/TimelineEventCard.svelte @@ -16,6 +16,7 @@ import ToolCallDetail from './ToolCallDetail.svelte'; import TodoUpdateDetail from './TodoUpdateDetail.svelte'; import ImageAttachments from '$lib/components/ImageAttachments.svelte'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; interface Props { event: TimelineEvent; @@ -356,6 +357,29 @@ />
{/if} + + + {#if !isExpanded && event.metadata?.image_attachments?.length} +
+ {#each (event.metadata.image_attachments as import('$lib/api-types').ImageAttachment[]).slice(0, 5) as attachment, i} +
+ Attached image {i + 1} +
+ {/each} + {#if (event.metadata.image_attachments as import('$lib/api-types').ImageAttachment[]).length > 5} + + +{(event.metadata.image_attachments as import('$lib/api-types').ImageAttachment[]).length - 5} more + + {/if} +
+ {/if}
@@ -393,7 +417,8 @@
{#if hasValue(input.content)} {@const content = String(input.content)} + {@const lineCount = content.split('\n').length}
-
- Content -
+
+
+ Content +
+ + {lineCount} line{lineCount === 1 ? '' : 's'} + +
-
{content}
+
+
{content}
+
{/if}
@@ -599,7 +610,7 @@
-
+
{@html renderedPlanHtml}
@@ -687,13 +698,14 @@ {@const taskId = String(input.taskId || '')} {@const status = String(input.status || '')} {@const subject = String(input.subject || '')} + {@const taskSubject = String(event.metadata?.task_subject || '')} {@const description = String(input.description || '')} {@const owner = String(input.owner || '')} {@const activeForm = String(input.activeForm || '')} {@const addBlocks = (input.addBlocks as string[]) || []} {@const addBlockedBy = (input.addBlockedBy as string[]) || []} {@const hasChanges = - status || subject || description || owner || activeForm || addBlocks.length > 0 || addBlockedBy.length > 0} + description || owner || activeForm || addBlocks.length > 0 || addBlockedBy.length > 0}
@@ -701,25 +713,32 @@
-
- - - Update Task - - {#if taskId} +
+
+ - #{taskId} + Update Task + + {#if taskId} + + #{taskId} + + {/if} +
+ {#if subject || taskSubject} + + {subject || taskSubject} {/if}
{#if status} {@const StatusIcon = getTaskStatusIcon(status)} @@ -731,18 +750,6 @@ {#if hasChanges}
- {#if subject} -
- - Subject - - {subject} -
- {/if} {#if description}
{/if} -
+
{@html renderedAgentContent}
{/snippet} diff --git a/frontend/src/routes/commands/[command_name]/+page.svelte b/frontend/src/routes/commands/[command_name]/+page.svelte index 952ee9cb..8c263d8f 100644 --- a/frontend/src/routes/commands/[command_name]/+page.svelte +++ b/frontend/src/routes/commands/[command_name]/+page.svelte @@ -13,6 +13,7 @@ Sparkles } from 'lucide-svelte'; import { formatDistanceToNow } from 'date-fns'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import PageHeader from '$lib/components/layout/PageHeader.svelte'; import StatsGrid from '$lib/components/StatsGrid.svelte'; import SegmentedControl from '$lib/components/ui/SegmentedControl.svelte'; @@ -305,7 +306,7 @@ {/snippet} {#snippet children()} -
+
{@html renderedContent}
{/snippet} diff --git a/frontend/src/routes/plugins/[plugin_id]/skills/[...path]/+page.svelte b/frontend/src/routes/plugins/[plugin_id]/skills/[...path]/+page.svelte index e59fb5a7..d8b8e90c 100644 --- a/frontend/src/routes/plugins/[plugin_id]/skills/[...path]/+page.svelte +++ b/frontend/src/routes/plugins/[plugin_id]/skills/[...path]/+page.svelte @@ -11,6 +11,7 @@ ChevronRight } from 'lucide-svelte'; import { marked } from 'marked'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import DOMPurify from 'isomorphic-dompurify'; import { formatDistanceToNow } from 'date-fns'; import PageHeader from '$lib/components/layout/PageHeader.svelte'; @@ -168,6 +169,7 @@
{@html renderedContent}
diff --git a/frontend/src/routes/skills/[...path]/+page.svelte b/frontend/src/routes/skills/[...path]/+page.svelte index c38ab22c..f9019930 100644 --- a/frontend/src/routes/skills/[...path]/+page.svelte +++ b/frontend/src/routes/skills/[...path]/+page.svelte @@ -14,6 +14,7 @@ ExternalLink } from 'lucide-svelte'; import { marked } from 'marked'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import DOMPurify from 'isomorphic-dompurify'; import { formatDistanceToNow } from 'date-fns'; import PageHeader from '$lib/components/layout/PageHeader.svelte'; @@ -244,7 +245,7 @@ >Preview
-
+
{@html renderedContent}
{/if} diff --git a/frontend/src/routes/skills/[skill_name]/+page.svelte b/frontend/src/routes/skills/[skill_name]/+page.svelte index 4aa6e1c1..7c80a433 100644 --- a/frontend/src/routes/skills/[skill_name]/+page.svelte +++ b/frontend/src/routes/skills/[skill_name]/+page.svelte @@ -18,6 +18,7 @@ Layers } from 'lucide-svelte'; import { formatDistanceToNow, isToday, isYesterday, isThisWeek, isThisMonth } from 'date-fns'; + import { markdownCopyButtons } from '$lib/actions/markdownCopyButtons'; import { onMount, tick } from 'svelte'; import PageHeader from '$lib/components/layout/PageHeader.svelte'; import StatsGrid from '$lib/components/StatsGrid.svelte'; @@ -646,7 +647,7 @@ {/snippet} {#snippet children()} -
+
{@html renderedContent}
{/snippet} From 2e3f5089617ec8f4a0661ffcab7113d66b3f8a80 Mon Sep 17 00:00:00 2001 From: Ayush Jhunjhunwala <48875674+the-non-expert@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:03:55 +0530 Subject: [PATCH 2/4] scroll position restore on navigation and last opened session highlight --- .../lib/components/GlobalSessionCard.svelte | 5 +- .../src/lib/components/SessionCard.svelte | 5 +- frontend/src/lib/search.ts | 7 +++ .../projects/[project_slug]/+page.svelte | 47 ++++++++++++++++++- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/GlobalSessionCard.svelte b/frontend/src/lib/components/GlobalSessionCard.svelte index 62bad90f..863ffeed 100644 --- a/frontend/src/lib/components/GlobalSessionCard.svelte +++ b/frontend/src/lib/components/GlobalSessionCard.svelte @@ -31,6 +31,7 @@ liveSession?: LiveSessionSummary | null; toolSource?: 'main' | 'subagent' | 'both'; subagentHref?: string; + highlighted?: boolean; } let { @@ -38,7 +39,8 @@ compact = false, liveSession = null, toolSource, - subagentHref + subagentHref, + highlighted = false }: Props = $props(); const showSubagentBadge = $derived(toolSource === 'subagent' || toolSource === 'both'); @@ -136,6 +138,7 @@ group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] {hasLiveStatus && !isRecentlyEnded ? 'ring-1 ring-opacity-50' : ''} + {highlighted ? 'session-highlight' : ''} overflow-hidden " style=" diff --git a/frontend/src/lib/components/SessionCard.svelte b/frontend/src/lib/components/SessionCard.svelte index cdcab8b4..04de75fb 100644 --- a/frontend/src/lib/components/SessionCard.svelte +++ b/frontend/src/lib/components/SessionCard.svelte @@ -20,6 +20,7 @@ showBranch?: boolean; // Hide branch when inside branch accordion compact?: boolean; // Compact mode for grid view liveSession?: LiveSessionSummary | null; // Live session data for real-time status + highlighted?: boolean; } let { @@ -27,7 +28,8 @@ projectEncodedName, showBranch = true, compact = false, - liveSession = null + liveSession = null, + highlighted = false }: Props = $props(); // Determine status (default to completed if not specified) @@ -131,6 +133,7 @@ group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)] {hasLiveStatus && !isRecentlyEnded ? 'ring-1 ring-opacity-50' : ''} + {highlighted ? 'session-highlight' : ''} overflow-hidden " style=" diff --git a/frontend/src/lib/search.ts b/frontend/src/lib/search.ts index 1b7f8227..428fca70 100644 --- a/frontend/src/lib/search.ts +++ b/frontend/src/lib/search.ts @@ -188,6 +188,8 @@ export interface FilterUrlOptions { extraParams?: Record; /** Param keys to clear when not on a specific tab (e.g., analytics params). Runs before extraParams. */ clearKeys?: string[]; + /** Current page number. Written to URL when > 1, deleted when 1 or unset. */ + page?: number; } /** @@ -276,6 +278,11 @@ export function buildFilterUrlParams(currentUrl: string, options: FilterUrlOptio url.searchParams.set('project', options.projectSlug || options.project); } + // ---- Page number (omit when page 1 — that's the default) ---- + if (options.page && options.page > 1) { + url.searchParams.set('page', options.page.toString()); + } + // ---- Extra params (analytics etc.) ---- if (options.extraParams) { for (const [key, value] of Object.entries(options.extraParams)) { diff --git a/frontend/src/routes/projects/[project_slug]/+page.svelte b/frontend/src/routes/projects/[project_slug]/+page.svelte index eea50f4b..4bad13c2 100644 --- a/frontend/src/routes/projects/[project_slug]/+page.svelte +++ b/frontend/src/routes/projects/[project_slug]/+page.svelte @@ -1,6 +1,6 @@