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 —
Title
preceded by 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}
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}
-