Skip to content

Commit 38321f5

Browse files
the-non-expertJayantDevkarclaude
authored
Timeline UX + multi-file memory viewer + captain-hook v2.1.92 expansion (#55)
* adding modular copy options for all the md to rich text viewer + timeline * scroll position restore on navigation and last opened session highlight * mapping update task tabs on timeline with actual task texts * fix: add skill_name route matcher to prevent file paths from matching [skill_name] segment * fix: address code review findings on PR #55 Five HIGH-severity issues plus supporting cleanups and tests. HIGH - ToolCallDetail: remove 'content' from skipKeys — the Write tool content preview was filtered out before the render path could read it, so feature #2 of PR #55 silently did not work. Kept 'task_subject' in skipKeys since it's read directly from event.metadata. - SessionCard + projects/[project_slug]: extract getSessionUrlIdentifier helper so the last-opened-highlight comparison uses the same slug/uuid resolution as SessionCard's href, fixing highlight loss on sessions where liveSession.slug differs from session.slug. - markdownCopyButtons: query h1/h2/h3 (was h2/h3); h1 and h2 always get a button, h3 keeps the 150-char gate. Matches the PR description. Removed stale h2-vs-h3 visual-distinction comment. - ToolCallDetail TaskUpdate: restore input.subject to hasChanges and add a "Rename to" row inside the Changes panel so a subject-only rename is visible as an explicit delta (header still shows inherited subject). - conversation_endpoints: tighten regex from r"Task #(\w+)" to r"Task #(\d+)" and move `import re` to module top. Added 3 regression tests in a new test_conversation_endpoints.py covering happy path, no matching TaskCreate, and unparseable result content. MEDIUM - markdownCopyButtons: delete dead `cleanupFns` array — it was populated but never invoked; DOM removal handles listener cleanup via GC. - jsonl_utils: replace copy.deepcopy(base) with {**base} shallow copy. The merge function only mutates the `message` key which is already rebuilt as a fresh dict, so deepcopy was wasted allocation for base64-image payloads. Drop `import copy`. - test_jsonl_utils: add TestMessageMerging with 8 tests — 4 direct unit tests of _merge_user_message_dicts (image-source drop, real-extra preservation, empty extra, legacy content key) and 4 integration tests via iter_messages_from_jsonl (same-timestamp merge, different-timestamp no-merge, cross-type no-merge, 3-way merge). - sessions/+page.svelte: make restoreScroll async and await tick() before requestAnimationFrame so scroll restore lands on fully-rendered DOM. LOW - ToolCallDetail: strip trailing newline before counting lines in the Write content preview so "a\nb\n" reads as 2 lines not 3. Verification - api: pytest tests/test_jsonl_utils.py tests/test_conversation_endpoints.py tests/test_session.py tests/test_agent.py — 149 passed. - frontend: svelte-check clean on all touched files (pre-existing errors only in skills/[skill_name=skill_name]/* from the PR's own route rename). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle [Image #N] marker in JSONL merge (v2.1.83+ format) Post-audit regression fix for the merge logic added in the previous commit. Claude Code v2.1.83 changed the image-attachment marker from `[Image: source: /var/folders/...]` to `[Image #N]`, with a trailing space added in v2.1.85+. The existing `startswith("[Image: source:")` check missed the new format, so `_merge_user_message_dicts` would leave the redundant marker text in the merged content for any session created by a Claude Code version released after 2026-03-25. Changes - Extract a `_is_image_marker_text` helper that matches both prefixes. - Update the docstring to call out the v2.1.83 + v2.1.85 format variants so the next schema change is easier to spot. - Add `test_merge_drops_image_hash_number_marker` covering a 2-image attachment with both `[Image #1]` and `[Image #2] ` (trailing space) marker variants in the extra message. Verification: pytest tests/test_jsonl_utils.py → 18 passed. Cite: Claude Code changelog v2.1.83 (2026-03-25) and v2.1.85 (2026-03-26). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(captain-hook): add 11 new hook types from Claude Code v2.1.83-v2.1.92 Expand the captain-hook Pydantic library with 11 new hook event types introduced between Claude Code v2.1.83 and v2.1.92, bringing the total from 13 to 24 supported hooks. New hook classes: Context: - InstructionsLoadedHook (CLAUDE.md / rules file loaded) User interaction: - PermissionDeniedHook (auto mode denied a tool call; can request retry) - ElicitationHook (MCP server requests structured input) - ElicitationResultHook (user response to MCP elicitation) Filesystem (new module fs_hooks.py): - CwdChangedHook (working directory changed) - FileChangedHook (external file change detected) Agent Teams (new module team_hooks.py, experimental): - TaskCreatedHook - TaskCompletedHook - TeammateIdleHook Worktree (new module worktree_hooks.py): - WorktreeCreateHook (HTTP hook can override worktreePath) - WorktreeRemoveHook Output model changes: - PreToolUseOutput.permission_decision now accepts "allow", "deny", "ask", and "defer" (the new "defer" value supports the headless `-p --resume` pause/resume flow). - New PermissionDeniedOutput with a single retry: bool field for requesting that Claude retry a denied tool call. Other changes: - HookEventName Literal in base.py extended with all 11 new event names. - HOOK_TYPE_MAP, HookEvent union, and __all__ in src/captain_hook/__init__.py extended with the new classes. - Backward-compat shim models.py mirrors the new exports. - 107 new tests added (fixtures, parser dispatch, round-trips, output models): 131 -> 238 total tests, all passing. - README.md and CLAUDE.md updated to reflect the 24-hook count and the new module layout. - 11 new docs/hooks/*-info-available.md files following the existing template. No existing hook classes or test cases were modified. * docs(features): Claude Code v2.1.81-v2.1.92 audit remediation plan 14 action items across 3 sprints tracking dashboard updates needed for Claude Code releases between 2026-03-20 and 2026-04-06. - Tier 1 (Blockers): JSONL merge regression (shipped), captain-hook expansion (11 new types), Teams discovery, bare mode detection - Tier 2 (High Priority): PowerShell/Teams tool recognition, frontmatter parsing (initialPrompt, shell, paths), managed-settings fragment merging, MCP metadata preservation, 14 new settings fields - Tier 3 (Experimental): Extended thinking visibility, hook conditional if support, session resumption hardening Cite: claude-code-guide audit (2026-04-06) * docs(features): multi-file project memory UI design Design for reworking MemoryViewer to handle the new auto-memory layout (MEMORY.md index + topical child files with YAML frontmatter). Reader- first UX: hover previews (Wikipedia-style) for link metadata, side panel drawer for full-content reading, collapsible orphan section for files not referenced by the index. Covers API changes (2 endpoints), component split, backwards compatibility, and test strategy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(api): multi-file project memory endpoints Replace the single-file /projects/{name}/memory response with a {index, files} shape that enumerates every *.md in the project's memory/ directory. Add /projects/{name}/memory/files/{filename} for fetching individual child file content on demand. - New schemas: MemoryIndexEntry, MemoryFileMeta, ProjectMemoryFileResponse - Hand-rolled YAML frontmatter parser (no new dependency) with graceful fallback on malformed blocks - Markdown link extraction computes linked_from_index for each child - Strict path validation on the per-file endpoint: regex allowlist, reject path separators / .. / leading dot / null byte, plus a resolve() + is_relative_to() containment check for symlink escapes - Backwards compatible: projects with only MEMORY.md return files=[] - 30 new tests covering all spec error cases and 12 path-traversal variants Design: docs/features/2026-04-07-multi-file-memory-ui.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(frontend): multi-file memory viewer with hover previews Rework the project memory UI to handle the new auto-memory layout (MEMORY.md index plus topical child files with YAML frontmatter). Reader-first UX: the index narrative is the page. Links within the index are rewritten to in-app interactive elements — hover shows a Wikipedia-style popover with the target file's metadata (type, word count, modified time), click opens the file in a right-side drawer. Orphan files (present on disk but not referenced from MEMORY.md) surface in a collapsible section at the bottom. - MemoryViewer.svelte: refactored into a shell that owns state, fetches the new {index, files} response, and debounces hover 150ms - MemoryIndex.svelte: renders MEMORY.md through the existing marked + DOMPurify pipeline, applies the rewriteMemoryLinks action - MemoryHoverCard.svelte: floating popover with manual viewport flip - MemoryFilePanel.svelte: bits-ui Dialog styled as a right-side sheet, fetches /memory/files/{filename} on open, handles 400/403/404/network errors with a Retry button, swaps content when filename changes - MemoryOrphanList.svelte: collapsible "Other memory files" section - rewriteMemoryLinks.ts: Svelte 5 action that post-processes the rendered DOM, matches a[href$=".md"] anchors against known files, attaches hover/click listeners, and marks broken links visually - api-types.ts: replaced ProjectMemory with the new {index, files} shape, added MemoryFileMeta, ProjectMemoryFile, MemoryFileType - vite.config.ts: added svelteTesting() plugin (VITEST-gated, no-op in production) to enable Svelte 5 component tests - 37 new tests across 4 files covering loading/error/orphan states, DOM rewriting lifecycle, hover card rendering, and panel refetch Integration point at +page.svelte:1725 unchanged. Design: docs/features/2026-04-07-multi-file-memory-ui.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(frontend): prevent stale fetch from clobbering memory panel state MemoryFilePanel.fetchFile() mutated shared state (fileData, bodyFading, loading, error) without tracking which invocation was current. This created three reachable bugs with a single root cause — no fetch lifecycle management: 1. Out-of-order resolution: click A → click B → A's fetch resolves after B's → fileData ends up as A (wrong file shown permanently). 2. bodyFading race: A's 80ms setTimeout could fire while B was still loading, briefly showing A without the fade-out covering it. 3. Close-during-fetch leak: closing the panel mid-fetch cleared state, but the in-flight fetch still wrote fileData on resolve, leaking stale content into the next open. Fix: add a generation counter (fetchGen). Each fetchFile captures ++fetchGen at entry and checks `myGen !== fetchGen` after every await and in the finally block. Superseded invocations drop their state writes silently. Panel close also bumps fetchGen to invalidate any in-flight fetch. Chose a gen counter over AbortController for simplicity — the bandwidth saved by actual cancellation is negligible for a localhost API, and the code stays in one mental model (no try/catch on AbortError, no extra imports). Adds a regression test that resolves an older fetch AFTER a newer one and asserts the newer file's content remains visible. Test fails against the pre-fix code at the exact expected assertion. Verification: 261 frontend tests pass (up from 260), svelte-check clean, production build succeeds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(frontend): dedupe markdown pipeline and type badge in memory components Addresses two HIGH quality findings from the code review of the multi-file memory UI feature. No behavioral change — verified by the existing 261-test suite including the race regression test. 1. Reuse existing renderMarkdownEffect helper MemoryIndex.svelte and MemoryFilePanel.svelte both reimplemented the marked + DOMPurify pipeline inline (the same ~12-line block each), when utils.ts already exports renderMarkdownEffect — a helper used by 4 other routes (agents, about, commands, skills). Two copies of a security-critical sanitization pipeline drift over time; centralize on the existing helper. 2. Extract shared type badge module TYPE_BADGE_CLASSES, TYPE_LABELS, badgeClass, and badgeLabel were duplicated byte-for-byte across MemoryHoverCard, MemoryFilePanel, and MemoryOrphanList (~25 lines each × 3 = ~75 lines). Extract to a single memoryTypeBadge.ts module. MemoryOrphanList's badge needed `shrink-0` because it lives inside a flex row — accept optional extra classes via a second parameter to badgeClass() rather than forcing per-call string concatenation at every site. Net: -84 lines (+18 / -102) across 4 edited files + 1 new module. Verification: 261 tests pass, svelte-check clean (0 errors), build succeeds. The fetch-race regression test from the previous commit continues to pass, confirming the refactor did not reintroduce any of the bugs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(api): remove unused pytest import in test_conversation_endpoints Fixes ruff F401 CI failure on PR #55. `pytest` was imported but never referenced — the tests use plain assert statements without fixtures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(api): apply ruff formatter to conversation_endpoints and jsonl_utils tests Fixes Python Lint CI failure on PR #55. `ruff format --check` flagged two test files with whitespace drift; applying the formatter is idempotent and matches the repo's existing ruff 0.15.9 config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jayant Devkar <55962509+JayantDevkar@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 359cc58 commit 38321f5

72 files changed

Lines changed: 6961 additions & 162 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/models/jsonl_utils.py

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,70 @@
1515
from .message import Message, parse_message
1616

1717

18+
def _is_image_marker_text(text: str) -> bool:
19+
"""
20+
Detect a text block that is an image-attachment marker Claude Code emits
21+
alongside the real image content block.
22+
23+
Two formats are observed across Claude Code versions:
24+
25+
- Pre-v2.1.83: ``[Image: source: /var/folders/...]``
26+
- v2.1.83+: ``[Image #N]`` (may have a trailing space in v2.1.85+)
27+
28+
Both are redundant because the actual image data is already present in a
29+
sibling ``image`` content block and should be dropped during merge.
30+
"""
31+
if not isinstance(text, str):
32+
return False
33+
return text.startswith("[Image: source:") or text.startswith("[Image #")
34+
35+
36+
def _merge_user_message_dicts(base: dict, extra: dict) -> dict:
37+
"""
38+
Merge two raw user message dicts that share the same timestamp.
39+
40+
Claude Code emits a pair of user messages at the same timestamp when
41+
an image is attached: the first contains the real text + base64 image
42+
block, and the second is a text-only fallback with a marker reference
43+
like ``[Image: source: /var/folders/...]`` (pre-v2.1.83) or
44+
``[Image #N]`` (v2.1.83+). We merge both into one dict so the
45+
downstream parser sees a single message with the correct content
46+
and image attachment.
47+
48+
The marker reference parts are dropped because the image data is
49+
already present in the base message's image content block. Any other
50+
real text in the extra message is preserved.
51+
"""
52+
merged = {**base}
53+
54+
def _get_content(d: dict) -> list:
55+
c = d.get("message", {}).get("content") or d.get("content", [])
56+
return c if isinstance(c, list) else []
57+
58+
base_content = _get_content(merged)
59+
extra_content = _get_content(extra)
60+
61+
# Keep extra parts that are not redundant image-marker text references
62+
real_extra = [
63+
part
64+
for part in extra_content
65+
if not (
66+
isinstance(part, dict)
67+
and part.get("type") == "text"
68+
and _is_image_marker_text(part.get("text", ""))
69+
)
70+
]
71+
72+
if real_extra:
73+
combined = base_content + real_extra
74+
if "message" in merged:
75+
merged["message"] = {**merged["message"], "content": combined}
76+
else:
77+
merged["content"] = combined
78+
79+
return merged
80+
81+
1882
def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]:
1983
"""
2084
Iterate over messages in a JSONL file.
@@ -23,6 +87,11 @@ def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]:
2387
parsed Message instances. Handles missing files, empty lines, and
2488
malformed JSON gracefully.
2589
90+
Consecutive user messages that share an identical timestamp are merged
91+
into a single message before parsing. Claude Code writes such pairs
92+
when the user attaches an image: one entry with the real text + base64
93+
image block and a second text-only entry with a file-path reference.
94+
2695
Args:
2796
jsonl_path: Path to the JSONL file containing messages.
2897
@@ -38,14 +107,40 @@ def iter_messages_from_jsonl(jsonl_path: Path) -> Iterator[Message]:
38107
if not jsonl_path.exists():
39108
return
40109

110+
pending: dict | None = None
111+
41112
with open(jsonl_path, "r", encoding="utf-8") as f:
42113
for line in f:
43114
line = line.strip()
44115
if not line:
45116
continue
46117
try:
47118
data = json.loads(line)
48-
yield parse_message(data)
49-
except (json.JSONDecodeError, ValueError, KeyError):
50-
# Skip malformed lines
119+
except json.JSONDecodeError:
120+
continue
121+
122+
# Merge consecutive user messages with the same timestamp into one
123+
if (
124+
pending is not None
125+
and pending.get("type") == "user"
126+
and data.get("type") == "user"
127+
and pending.get("timestamp") == data.get("timestamp")
128+
):
129+
pending = _merge_user_message_dicts(pending, data)
51130
continue
131+
132+
# Yield the previously buffered message
133+
if pending is not None:
134+
try:
135+
yield parse_message(pending)
136+
except (ValueError, KeyError):
137+
pass
138+
139+
pending = data
140+
141+
# Yield the final buffered message
142+
if pending is not None:
143+
try:
144+
yield parse_message(pending)
145+
except (ValueError, KeyError):
146+
pass

0 commit comments

Comments
 (0)