Skip to content

Commit 276f438

Browse files
committed
chore(release): v0.5.1
Session-watcher / picker-refresh release. - New: viewport-driven picker refresh (IntersectionObserver + heartbeat) - Fixed: TUI subscription to picker-refresh event (was picker-update) - Fixed: web picker re-fetch on picker-refresh SSE signal See CHANGELOG.md for details.
1 parent 3bb3191 commit 276f438

14 files changed

Lines changed: 506 additions & 9 deletions

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Changelog
2+
3+
All notable changes to claude-code-trace are documented here. Versions follow
4+
[semantic versioning](https://semver.org/).
5+
6+
## [0.5.1] — 2026-05-20
7+
8+
A release focused entirely on the session-watcher / picker-refresh path. Brings the picker
9+
closer to a live view: the cards you can see on screen update without waiting for filesystem
10+
events, and a long-standing TUI bug that silently disabled picker auto-refresh is fixed.
11+
12+
### Added
13+
14+
- **Viewport-driven picker refresh in the web frontend**. The picker now wraps every session
15+
card in a shared `IntersectionObserver` (`useVisibleSessions` hook) and re-fetches the
16+
session list whenever the visible set changes — debounced 150 ms — plus a 2 s heartbeat
17+
while any cards are visible. This means the cards the user is actually looking at stay
18+
fresh without paying the cost of polling everything all the time, and ongoing-status /
19+
token-count badges update much closer to real time. Verified end-to-end in a real browser
20+
(Chromium): 549/549 cards observed, scroll triggers a `POST /api/sessions` at ~T+1.3 s,
21+
then the heartbeat keeps the list fresh while the picker is mounted.
22+
23+
### Fixed
24+
25+
- **TUI picker auto-refresh**
26+
([`ebb2ca5`](https://github.com/delexw/claude-code-trace/commit/ebb2ca5)). The terminal UI
27+
subscribed to a non-existent `picker-update` event and destructured `payload.sessions` from
28+
it, while the backend emits `picker-refresh` with an empty payload
29+
(`src-tauri/src/watcher.rs:340`). The TUI picker never auto-updated when sessions changed
30+
on disk — it only refreshed when the user re-entered the picker view. The TUI now
31+
subscribes to `picker-refresh` and re-fetches via `api.discoverSessions(dirs)`, mirroring
32+
the web frontend pattern in `src/hooks/usePicker.ts`.
33+
34+
- **Web picker re-fetch on `picker-refresh` signal**
35+
([`01f8212`](https://github.com/delexw/claude-code-trace/commit/01f8212)). Memoise the most
36+
recent `projectDirs` in a ref so the SSE handler can re-issue `discover_sessions` without
37+
the caller needing to thread state through.
38+
39+
[0.5.1]: https://github.com/delexw/claude-code-trace/releases/tag/v0.5.1

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "claude-code-trace",
3-
"version": "0.5.0",
3+
"version": "0.5.1",
44
"private": true,
55
"bin": {
66
"cctrace": "./bin/cctrace.mjs"

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "claude-code-trace"
3-
version = "0.5.0"
3+
version = "0.5.1"
44
edition = "2021"
55
rust-version = "1.77.2"
66

src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ export function App() {
312312
onSelect={handleSelectSession}
313313
onSearchChange={picker.setSearchQuery}
314314
onSelectIndex={setPickerSelectedIndex}
315+
onVisiblePathsChange={picker.refresh}
315316
/>
316317
);
317318

src/components/SessionPicker.test.tsx

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,44 @@
1-
import { describe, it, expect, vi } from "vitest";
2-
import { render, screen, fireEvent } from "@testing-library/react";
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { render, screen, fireEvent, act } from "@testing-library/react";
33
import { SessionPicker } from "./SessionPicker";
44
import type { SessionInfo } from "../types";
55

6+
type IOCallback = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void;
7+
8+
class FakeIO {
9+
static last: FakeIO | null = null;
10+
cb: IOCallback;
11+
observed: Element[] = [];
12+
observe = (el: Element) => {
13+
this.observed.push(el);
14+
};
15+
unobserve = vi.fn();
16+
disconnect = vi.fn();
17+
takeRecords = vi.fn(() => [] as IntersectionObserverEntry[]);
18+
root = null;
19+
rootMargin = "";
20+
thresholds: number[] = [];
21+
constructor(cb: IOCallback) {
22+
this.cb = cb;
23+
FakeIO.last = this;
24+
}
25+
trigger(els: Element[]) {
26+
const entries = els.map(
27+
(el) =>
28+
({
29+
target: el,
30+
isIntersecting: true,
31+
intersectionRatio: 1,
32+
boundingClientRect: new DOMRect(),
33+
intersectionRect: new DOMRect(),
34+
rootBounds: new DOMRect(),
35+
time: 0,
36+
}) as unknown as IntersectionObserverEntry,
37+
);
38+
this.cb(entries, this as unknown as IntersectionObserver);
39+
}
40+
}
41+
642
function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
743
return {
844
path: "/home/user/.claude/projects/proj/session1.jsonl",
@@ -191,4 +227,60 @@ describe("SessionPicker", () => {
191227
);
192228
expect(screen.queryByText(/Discovering sessions/)).not.toBeInTheDocument();
193229
});
230+
231+
describe("viewport-aware visibility tracking", () => {
232+
beforeEach(() => {
233+
FakeIO.last = null;
234+
(globalThis as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
235+
vi.useFakeTimers();
236+
});
237+
afterEach(() => {
238+
vi.useRealTimers();
239+
});
240+
241+
it("observes each session card and reports visible paths via onVisiblePathsChange", () => {
242+
const onVisible = vi.fn();
243+
const sessions = [
244+
makeSession({ path: "/a.jsonl", session_id: "a", first_message: "alpha" }),
245+
makeSession({ path: "/b.jsonl", session_id: "b", first_message: "beta" }),
246+
];
247+
render(
248+
<SessionPicker
249+
sessions={sessions}
250+
loading={false}
251+
searchQuery=""
252+
selectedIndex={0}
253+
onSelect={vi.fn()}
254+
onSearchChange={vi.fn()}
255+
onVisiblePathsChange={onVisible}
256+
/>,
257+
);
258+
expect(FakeIO.last).not.toBeNull();
259+
expect(FakeIO.last!.observed).toHaveLength(2);
260+
261+
act(() => {
262+
FakeIO.last!.trigger(FakeIO.last!.observed);
263+
vi.advanceTimersByTime(150);
264+
});
265+
expect(onVisible).toHaveBeenCalledExactlyOnceWith(
266+
expect.arrayContaining(["/a.jsonl", "/b.jsonl"]),
267+
);
268+
});
269+
270+
it("works without onVisiblePathsChange (no-op observer)", () => {
271+
const sessions = [makeSession()];
272+
expect(() =>
273+
render(
274+
<SessionPicker
275+
sessions={sessions}
276+
loading={false}
277+
searchQuery=""
278+
selectedIndex={0}
279+
onSelect={vi.fn()}
280+
onSearchChange={vi.fn()}
281+
/>,
282+
),
283+
).not.toThrow();
284+
});
285+
});
194286
});

src/components/SessionPicker.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useRef, useMemo } from "react";
22
import { useScrollToSelected } from "../hooks/useScrollToSelected";
3+
import { useVisibleSessions } from "../hooks/useVisibleSessions";
34
import type { SessionInfo } from "../types";
45
import { OngoingDots } from "./OngoingDots";
56
import {
@@ -12,6 +13,7 @@ import {
1213
shortModel,
1314
} from "../lib/format";
1415
import { getModelColor } from "../lib/theme";
16+
import { mergeRefs } from "../lib/mergeRefs";
1517
import { BsClaude } from "react-icons/bs";
1618
import { TokensIcon, CostIcon, ForwardIcon } from "./Icons";
1719

@@ -23,6 +25,12 @@ interface SessionPickerProps {
2325
onSelect: (session: SessionInfo) => void;
2426
onSearchChange: (query: string) => void;
2527
onSelectIndex?: (index: number) => void;
28+
/**
29+
* Called (debounced) with the paths of session cards currently in the viewport,
30+
* and again periodically on a heartbeat while any cards remain visible. The caller
31+
* uses this as a cue to refresh fresh session info.
32+
*/
33+
onVisiblePathsChange?: (paths: string[]) => void;
2634
}
2735

2836
export function SessionPicker({
@@ -33,10 +41,12 @@ export function SessionPicker({
3341
onSelect,
3442
onSearchChange,
3543
onSelectIndex,
44+
onVisiblePathsChange,
3645
}: SessionPickerProps) {
3746
const listRef = useRef<HTMLDivElement>(null);
3847
const selectedRef = useScrollToSelected(selectedIndex);
3948
const searchRef = useRef<HTMLInputElement>(null);
49+
const registerVisible = useVisibleSessions(onVisiblePathsChange ?? noop);
4050

4151
const dateGroups = groupByDate(sessions);
4252

@@ -106,7 +116,7 @@ export function SessionPicker({
106116
return (
107117
<div
108118
key={session.path}
109-
ref={isSelected ? selectedRef : undefined}
119+
ref={mergeRefs(isSelected ? selectedRef : null, registerVisible(session.path))}
110120
className={`picker__session${isSelected ? " picker__session--selected" : ""}${session.is_ongoing ? " picker__session--ongoing" : ""}`}
111121
onMouseEnter={() => onSelectIndex?.(idx)}
112122
onClick={() => onSelect(session)}
@@ -169,3 +179,5 @@ export function SessionPicker({
169179
</div>
170180
);
171181
}
182+
183+
function noop() {}

src/hooks/usePicker.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ export function usePicker(selectedProject: string | null = null) {
4848
[fetchSessions],
4949
);
5050

51+
/**
52+
* Re-fetch the session list using the most recently supplied project dirs.
53+
* Used by the viewport-aware picker to refresh visible cards eagerly,
54+
* independent of file-system events. Cheap: coalesced by the backend's
55+
* sessions cache.
56+
*/
57+
const refresh = useCallback(() => {
58+
const dirs = projectDirsRef.current;
59+
if (!dirs) return;
60+
fetchSessions(dirs).catch((err) => {
61+
console.error("Failed to refresh sessions:", err);
62+
});
63+
}, [fetchSessions]);
64+
5165
const setSearchQuery = useCallback((query: string) => {
5266
setState((prev) => ({ ...prev, searchQuery: query }));
5367
}, []);
@@ -104,6 +118,7 @@ export function usePicker(selectedProject: string | null = null) {
104118
searchQuery: state.searchQuery,
105119
setSearchQuery,
106120
discoverSessions,
121+
refresh,
107122
updateSessionOngoing,
108123
};
109124
}

0 commit comments

Comments
 (0)