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/services/conversation_endpoints.py b/api/services/conversation_endpoints.py index f926e9c0..2a3928da 100644 --- a/api/services/conversation_endpoints.py +++ b/api/services/conversation_endpoints.py @@ -64,6 +64,28 @@ def build_conversation_timeline( # Pass 1: Collect all tool results for later merging tool_results = collect_tool_results(conversation, extract_spawned_agent=True, parse_xml=True) + # Pass 1b: Collect taskId → subject from TaskCreate calls so TaskUpdate + # events can display the task description even though updates only send taskId + status. + # + # TaskCreate input has 'subject' but NO 'taskId' — the ID is assigned by the + # task runtime and returned in the result as "Task #N created successfully: ...". + # We parse it from the result content and map it to the input subject. + import re as _re + + task_subjects: dict[str, str] = {} + for msg in conversation.iter_messages(): + if isinstance(msg, AssistantMessage): + for block in msg.content_blocks: + if isinstance(block, ToolUseBlock) and block.name == "TaskCreate": + subject = str(block.input.get("subject", "")) + if not subject: + continue + result = tool_results.get(block.id) + if result: + m = _re.search(r"Task #(\w+)", result.content) + if m: + task_subjects[m.group(1)] = subject + # Pass 2: Build events with merged results events: list[TimelineEvent] = [] event_counter = 0 @@ -150,7 +172,7 @@ def build_conversation_timeline( # Build complete metadata with merged result metadata = _build_tool_call_metadata( - block, base_metadata, result_data, subagent_info + block, base_metadata, result_data, subagent_info, task_subjects ) # Add agent context for subagent messages @@ -308,6 +330,7 @@ def _build_tool_call_metadata( base_metadata: dict, result_data: Optional[ToolResultData], subagent_info: dict[str, Optional[str]], + task_subjects: Optional[dict[str, str]] = None, ) -> dict: """Build complete metadata for a tool call, merging in result if available.""" metadata = {"tool_name": block.name, "tool_id": block.id, **base_metadata} @@ -316,6 +339,12 @@ def _build_tool_call_metadata( if block.name in ("Task", "Agent"): metadata["is_spawn_task"] = True + # Annotate TaskUpdate with the subject from the matching TaskCreate + if block.name == "TaskUpdate" and task_subjects: + task_id = str(block.input.get("taskId", "")) + if task_id and task_id in task_subjects: + metadata["task_subject"] = task_subjects[task_id] + if result_data is None: return metadata 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/api/utils.py b/api/utils.py index 19b35af4..239616d2 100644 --- a/api/utils.py +++ b/api/utils.py @@ -776,7 +776,8 @@ def to_relative(path: str) -> str: return "Read file", to_relative(path), {"path": path} elif tool_name == "Write": path = tool_input.get("path") or tool_input.get("file_path", "") - return "Write file", to_relative(path), {"path": path} + content = tool_input.get("content", "") + return "Write file", to_relative(path), {"path": path, "content": content} elif tool_name == "Edit" or tool_name == "StrReplace": path = tool_input.get("path") or tool_input.get("file_path", "") return "Edit file", to_relative(path), {"path": path} 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/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/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/SkillList.svelte b/frontend/src/lib/components/skills/SkillList.svelte index 28ce660b..cf25b5fa 100644 --- a/frontend/src/lib/components/skills/SkillList.svelte +++ b/frontend/src/lib/components/skills/SkillList.svelte @@ -23,6 +23,15 @@ let { projectEncodedName, currentPath = '' }: Props = $props(); + // When embedded in project tab, we manage path locally (no URL changes). + // When on the global /skills page, currentPath is driven by the URL prop. + let internalPath = $state(currentPath); + + // Keep in sync with prop for global-skills-page usage (currentPath changes via URL) + $effect(() => { + if (!projectEncodedName) internalPath = currentPath; + }); + let items = $state([]); let loading = $state(true); let error = $state(null); @@ -32,7 +41,7 @@ let newSkillName = $state(''); $effect(() => { - fetchSkills(currentPath); + fetchSkills(internalPath); }); async function fetchSkills(path: string) { @@ -63,7 +72,7 @@ function createSkill() { if (!newSkillName.trim()) return; - let fullPath = currentPath ? `${currentPath}/${newSkillName}` : newSkillName; + let fullPath = internalPath ? `${internalPath}/${newSkillName}` : newSkillName; if (!fullPath.endsWith('.md') && !fullPath.endsWith('.txt')) { fullPath += '.md'; @@ -90,16 +99,18 @@ ? `project=${encodeURIComponent(projectEncodedName)}` : ''; if (item.type === 'directory') { - const base = `/skills?path=${item.path}`; - return projectParam ? `${base}&${projectParam}` : base; + // In project context, directories are handled via local state (onclick). + // For global /skills page, build a real URL. + if (projectEncodedName) return '#'; + return `/skills?path=${item.path}`; } const base = `/skills/${item.path}`; return projectParam ? `${base}?${projectParam}` : base; } function getBackHref(): string { - const parentPath = currentPath.includes('/') - ? currentPath.split('/').slice(0, -1).join('/') + const parentPath = internalPath.includes('/') + ? internalPath.split('/').slice(0, -1).join('/') : ''; const projectParam = projectEncodedName ? `project=${encodeURIComponent(projectEncodedName)}` @@ -155,7 +166,7 @@ />

Will be created in /{currentPath}/{internalPath}

@@ -181,9 +192,17 @@
- {#if currentPath} + {#if internalPath} { + if (projectEncodedName) { + e.preventDefault(); + internalPath = internalPath.includes('/') + ? internalPath.split('/').slice(0, -1).join('/') + : ''; + } + }} class="p-2 hover:bg-[var(--bg-subtle)] rounded-lg text-[var(--text-muted)] transition-colors" aria-label="Go back to parent folder" > @@ -222,6 +241,12 @@ {#if item.type === 'directory'} { + if (projectEncodedName) { + e.preventDefault(); + internalPath = item.path; + } + }} class="group flex items-center gap-4 p-4 bg-[var(--bg-base)] border border-[var(--border)] rounded-xl hover:border-[var(--accent)]/50 hover:shadow-sm transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2" data-list-item > @@ -278,14 +303,17 @@ {/if} - {#if !currentPath} + {#if !internalPath}
+ projectEncodedName + ? `/skills/${name}?project=${encodeURIComponent(projectEncodedName)}` + : `/skills/${name}`} />
{/if} 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}
; /** 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/params/skill_name.ts b/frontend/src/params/skill_name.ts new file mode 100644 index 00000000..e7495535 --- /dev/null +++ b/frontend/src/params/skill_name.ts @@ -0,0 +1,8 @@ +/** + * Matcher for the [skill_name] route segment. + * Only matches skill names without file extensions (e.g. "my-skill" but not "my-skill.md"). + * This ensures paths like /skills/sticky-stack-scroll.md fall through to [...path]. + */ +export function match(value: string): boolean { + return !/\.[a-zA-Z0-9]+$/.test(value); +} diff --git a/frontend/src/routes/agents/[name]/+page.svelte b/frontend/src/routes/agents/[name]/+page.svelte index 144b4e1b..abe9a437 100644 --- a/frontend/src/routes/agents/[name]/+page.svelte +++ b/frontend/src/routes/agents/[name]/+page.svelte @@ -25,6 +25,7 @@ Terminal } 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'; @@ -566,7 +567,7 @@
{/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/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 @@