Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions api/models/jsonl_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,60 @@

from __future__ import annotations

import copy
import json
from pathlib import Path
from typing import Iterator

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.
Expand All @@ -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.

Expand All @@ -38,14 +90,40 @@ 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()
if not line:
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
31 changes: 30 additions & 1 deletion api/services/conversation_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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

Expand Down
12 changes: 9 additions & 3 deletions api/tests/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion api/tests/test_jsonl_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 3 additions & 0 deletions api/tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
104 changes: 104 additions & 0 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
============================================ */
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading