diff --git a/data/sessions.html b/data/sessions.html index 1b72580..ccabdd2 100644 --- a/data/sessions.html +++ b/data/sessions.html @@ -435,6 +435,17 @@

Daemon

// card and releases outside it. document.addEventListener("mouseup", () => { isMouseDownOnCard = false; }); + // Blur a focused group-name field when the user clicks outside it. Without + // this, the field stays focused indefinitely after a "+ New group" click + + // walk-away, deferring the structural rebuild forever. Pointerdown (not + // click) so we beat the natural focus transfer. + document.addEventListener("pointerdown", (e) => { + const editing = document.activeElement; + if (!editing || !editing.classList?.contains("name") || !editing.isContentEditable) return; + if (editing.contains(e.target)) return; // click inside the editing field — leave focus alone + editing.blur(); + }); + function basename(p) { if (!p) return "—"; const parts = String(p).split(/[\\/]/).filter(Boolean); @@ -1153,6 +1164,18 @@

Daemon

for (const s of sessionsState) updateCardInPlace(s); return; } + + // If the user is editing a group name, defer the structural rebuild — + // it would rip the focused contenteditable out of the DOM and lose + // the user's in-progress edit. The fast-path above keeps card pills + // and "synced Xs ago" updating during the edit; structural changes + // (new group, dropped pin, joining session) wait until blur. The + // very next render() after blur picks them up. + if (isEditingName()) { + for (const s of sessionsState) updateCardInPlace(s); + return; + } + lastStructuralSig = sig; groupsContainer.textContent = ""; @@ -1249,7 +1272,7 @@

Daemon

} async function poll() { - if (isDragging || isMouseDownOnCard || isEditingName() || isAnyDeleteArmed()) return; + if (isDragging || isMouseDownOnCard || isAnyDeleteArmed()) return; if (Date.now() - lastWriteAt < TIMINGS.writeSettleMs) return; try { const [sRes, gRes] = await Promise.all([ diff --git a/index.mjs b/index.mjs index 035bf4f..5fc3cba 100644 --- a/index.mjs +++ b/index.mjs @@ -172,7 +172,7 @@ function startNameWatch() { // Activity watch: every 5 s, ask the host adapter for a snapshot and push // it to the daemon. The adapter owns host-specific transcript semantics -// (JSONL tailing for Claude, rewritten-JSON re-read for Gemini, ...). +// (JSONL tailing for Claude/Codex, JSON or JSONL re-read for Gemini). function startActivityWatch() { const t = setInterval(async () => { if (!adapter) return; diff --git a/lib/host/__fixtures__/gemini-jsonl-basic.jsonl b/lib/host/__fixtures__/gemini-jsonl-basic.jsonl new file mode 100644 index 0000000..03ceaff --- /dev/null +++ b/lib/host/__fixtures__/gemini-jsonl-basic.jsonl @@ -0,0 +1,4 @@ +{"sessionId":"abc","projectHash":"hash","startTime":"2026-04-23T17:00:00Z","lastUpdated":"2026-04-23T17:00:00Z","kind":"main","directories":[]} +{"id":"m1","timestamp":"2026-04-23T17:00:01Z","type":"user","content":"hi"} +{"id":"m2","timestamp":"2026-04-23T17:00:02Z","type":"gemini","content":"hello","model":"gemini-pro","toolCalls":[{"name":"mcp_sessions-dashboard_open_dashboard","timestamp":"2026-04-23T17:00:02Z","status":"success"}]} +{"$set":{"lastUpdated":"2026-04-23T17:00:03Z"}} diff --git a/lib/host/__fixtures__/gemini-jsonl-malformed.jsonl b/lib/host/__fixtures__/gemini-jsonl-malformed.jsonl new file mode 100644 index 0000000..7f74a97 --- /dev/null +++ b/lib/host/__fixtures__/gemini-jsonl-malformed.jsonl @@ -0,0 +1,4 @@ +{"sessionId":"s1","projectHash":"h1","startTime":"2026-04-23T17:00:00Z","lastUpdated":"2026-04-23T17:00:00Z","kind":"main","directories":[]} +{"id":"m1","timestamp":"2026-04-23T17:00:01Z","type":"user","content":"hi"} +{garbled line that won't JSON.parse +{"id":"m2","timestamp":"2026-04-23T17:00:02Z","type":"gemini","content":"reply"} diff --git a/lib/host/__fixtures__/gemini-jsonl-rewind.jsonl b/lib/host/__fixtures__/gemini-jsonl-rewind.jsonl new file mode 100644 index 0000000..147fcdc --- /dev/null +++ b/lib/host/__fixtures__/gemini-jsonl-rewind.jsonl @@ -0,0 +1,6 @@ +{"sessionId":"s1","projectHash":"h1","startTime":"2026-04-23T17:00:00Z","lastUpdated":"2026-04-23T17:00:00Z","kind":"main","directories":[]} +{"id":"m1","timestamp":"2026-04-23T17:00:01Z","type":"user","content":"first"} +{"id":"m2","timestamp":"2026-04-23T17:00:02Z","type":"gemini","content":"reply"} +{"id":"m3","timestamp":"2026-04-23T17:00:03Z","type":"user","content":"undo me"} +{"$rewindTo":"m3"} +{"id":"m3b","timestamp":"2026-04-23T17:00:10Z","type":"user","content":"replacement"} diff --git a/lib/host/__fixtures__/gemini-jsonl-set.jsonl b/lib/host/__fixtures__/gemini-jsonl-set.jsonl new file mode 100644 index 0000000..c5570d9 --- /dev/null +++ b/lib/host/__fixtures__/gemini-jsonl-set.jsonl @@ -0,0 +1,4 @@ +{"sessionId":"s1","projectHash":"h1","startTime":"2026-04-23T17:00:00Z","lastUpdated":"2026-04-23T17:00:00Z","kind":"main","directories":[]} +{"id":"m1","timestamp":"2026-04-23T17:00:01Z","type":"user","content":"hi"} +{"$set":{"lastUpdated":"2026-04-23T17:05:00Z","summary":"first turn done"}} +{"id":"m2","timestamp":"2026-04-23T17:05:30Z","type":"gemini","content":"sure"} diff --git a/lib/host/__fixtures__/gemini-legacy.json b/lib/host/__fixtures__/gemini-legacy.json new file mode 100644 index 0000000..22fa703 --- /dev/null +++ b/lib/host/__fixtures__/gemini-legacy.json @@ -0,0 +1,13 @@ +{ + "sessionId": "537ac3af-4f37-4f54-9711-bb4b2468d6c8", + "projectHash": "560e99986dced0026510009353f499039e6c8e5348b747d298848aef378095b4", + "startTime": "2026-04-22T04:13:55.394Z", + "lastUpdated": "2026-04-22T04:14:21.274Z", + "messages": [ + { "id": "m1", "type": "user", "timestamp": "2026-04-22T04:14:00.000Z", "content": "hi" }, + { "id": "m2", "type": "gemini", "timestamp": "2026-04-22T04:14:10.000Z", "content": "hello", + "toolCalls": [ + { "name": "mcp_sessions-dashboard_open_dashboard", "timestamp": "2026-04-22T04:14:05.000Z", "status": "success" } + ] } + ] +} diff --git a/lib/host/gemini.mjs b/lib/host/gemini.mjs index ecdf840..59309ca 100644 --- a/lib/host/gemini.mjs +++ b/lib/host/gemini.mjs @@ -1,12 +1,23 @@ -// Gemini CLI adapter. Gemini stores each session as a single JSON file at -// ~/.gemini/tmp//chats/session--.json +// Gemini CLI adapter. Gemini stores each session as a chat file at +// ~/.gemini/tmp//chats/session--.{json,jsonl} // The `.project_root` file in each tmp/ holds the canonical cwd, so we // don't rely on a hash convention we can't verify across Gemini versions. // -// Critical difference from Claude: Gemini REWRITES the whole session JSON -// atomically on each update — it is NOT an append-only JSONL. We can't tail -// by byte offset. Instead we mtime-gate the file: only re-parse when the -// mtime advances. Bounded by session size (sub-MB even for long sessions). +// Two formats coexist (Gemini preserves history across the bump rather than +// migrating), and we dispatch by extension: +// +// .json (Gemini <= 0.38): atomic full-file rewrite. Single JSON.parse on +// read. mtime-gated so we re-parse only when the file changes. +// .jsonl (Gemini >= 0.39, per PR google-gemini/gemini-cli#23749): append- +// only stream. Each line is a record; parseJsonl() materializes a +// ConversationRecord by walking lines (line 1 is header, subsequent +// lines are MessageRecord, MetadataUpdateRecord {$set:{...}}, or +// RewindRecord {$rewindTo:""}). Per-line parse failures are +// swallowed so a partial trailing line doesn't drop earlier records. +// +// Both formats produce the same ConversationRecord shape, so _deriveSnapshot +// is format-agnostic and unchanged. Mtime-gating still applies for both; +// JSONL re-parsing is sub-millisecond at the sub-MB sizes Gemini produces. // // Activity derivation from the schema (verified against a live session): // messages: [ { id, timestamp, type: "user"|"gemini", content, ... } ] @@ -36,6 +47,49 @@ const STALE_RUNNING_MS = 5 * 60 * 1000; // first user message). Keep this forgiving. const SESSION_MATCH_WINDOW_MS = 5 * 60 * 1000; +// Replay a Gemini 0.39.0+ .jsonl chat log into the same +// ConversationRecord shape that legacy .json files have, so the +// existing _deriveSnapshot() can consume either format unchanged. +// +// Schema (from google-gemini/gemini-cli PR #23749): +// line 1: PartialMetadataRecord (header) +// line N: MessageRecord ({id, type, timestamp, content, toolCalls?}) +// OR MetadataUpdateRecord ({$set: {...}}) +// OR RewindRecord ({$rewindTo: ""}) +// Unknown record shapes are ignored (forward-compat). Per-line JSON +// parse failures are swallowed — handles mid-write partial trailing +// lines and forward-compat garbage without losing prior records. +function parseJsonl(text) { + const conversation = { messages: [] }; + for (const raw of text.split("\n")) { + if (!raw) continue; + let rec; + try { rec = JSON.parse(raw); } catch { continue; } + if (!rec || typeof rec !== "object") continue; + if (rec.$set && typeof rec.$set === "object") { + Object.assign(conversation, rec.$set); + continue; + } + if (typeof rec.$rewindTo === "string") { + const idx = conversation.messages.findIndex((m) => m?.id === rec.$rewindTo); + if (idx >= 0) conversation.messages.length = idx; + continue; + } + if (rec.id && rec.type) { + conversation.messages.push(rec); + continue; + } + // Header / extra metadata (has sessionId but no id+type pair). + if (rec.sessionId && !rec.id) { + Object.assign(conversation, rec); + if (!Array.isArray(conversation.messages)) conversation.messages = []; + continue; + } + // anything else: ignore (forward-compat) + } + return conversation; +} + export class GeminiAdapter extends HostAdapter { name = HOST.GEMINI; displayName = "Gemini CLI"; @@ -44,7 +98,7 @@ export class GeminiAdapter extends HostAdapter { super(ctx); this._chatFilePath = null; // resolved on first locate() success this._lastMtimeMs = null; // mtime at last scan — gates re-read only - this._parsedCache = null; // cached parsed JSON (invalidated on mtime change) + this._parsedCache = null; // cached parsed ConversationRecord (invalidated on mtime change) this._snapshotCache = null; // last produced ActivitySnapshot (for failure fallback) this._ready = false; // true after first successful scan } @@ -113,7 +167,11 @@ export class GeminiAdapter extends HostAdapter { let fallback = null; let fallbackMtime = 0; for (const f of chatFiles) { - if (!f.startsWith("session-") || !f.endsWith(".json")) continue; + // Accept both legacy atomic .json (Gemini ≤0.38) and append-only + // .jsonl (Gemini ≥0.39, per PR google-gemini/gemini-cli#23749). The + // startTime regex peek below works for both because the field + // appears near the top of either format. + if (!f.startsWith("session-") || !(f.endsWith(".json") || f.endsWith(".jsonl"))) continue; const fp = path.join(chatsDir, f); let st; try { st = await fsp.stat(fp); } catch { continue; } @@ -262,9 +320,18 @@ export class GeminiAdapter extends HostAdapter { try { text = await fsp.readFile(fp, "utf8"); } catch { return this._ready ? this._snapshotCache : null; } - try { this._parsedCache = JSON.parse(text); } catch { - // Mid-write race is extremely unlikely (Gemini writes atomically); - // keep the last-known parsed state rather than publishing null. + // Dispatch by extension. Gemini 0.39.0+ writes append-only .jsonl; + // earlier versions wrote atomic .json. Both coexist in the same + // chats/ dir because Gemini preserves history across the format + // bump rather than migrating. _locate accepts both extensions. + const isJsonl = fp.endsWith(".jsonl"); + try { + this._parsedCache = isJsonl ? parseJsonl(text) : JSON.parse(text); + } catch { + // .json mid-write race is rare (atomic writes); .jsonl line-level + // parse failures are already swallowed inside parseJsonl. A throw + // here means the whole file is unreadable — keep the last-known + // snapshot rather than publishing null. return this._ready ? this._snapshotCache : null; } this._lastMtimeMs = st.mtimeMs; diff --git a/lib/host/gemini.test.mjs b/lib/host/gemini.test.mjs new file mode 100644 index 0000000..ffeb861 --- /dev/null +++ b/lib/host/gemini.test.mjs @@ -0,0 +1,102 @@ +// Tests for GeminiAdapter. Run with: +// node --test lib/host/gemini.test.mjs +// No external test framework — uses Node's built-in `node:test`. +// +// Tests bypass `_locate()` (the disk-walking session-finder) by monkey- +// patching it to return the fixture path. scanActivity() always re-calls +// _locate at the top, so setting _chatFilePath directly does not work. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { writeFileSync, readFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { GeminiAdapter } from "./gemini.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FIX = (n) => join(__dirname, "__fixtures__", n); + +test("test runner sanity", () => { + assert.equal(1 + 1, 2); +}); + +test("legacy .json: parses atomic ConversationRecord and derives snapshot", async () => { + const a = new GeminiAdapter({ + cwd: "/irrelevant", + sessionStart: "2026-04-22T04:13:55.394Z", + pid: 1, + }); + a._locate = async () => FIX("gemini-legacy.json"); + const snap = await a.scanActivity(); + assert.equal(snap.count, 1, "one tool call counted across the conversation"); + assert.equal(snap.activityState, "idle", "no recent calls within RECENT_CALL_MS → idle"); + assert.equal(snap.toolName, null); +}); + +test("jsonl basic: parses replay records into ConversationRecord", async () => { + const a = new GeminiAdapter({ + cwd: "/irrelevant", + sessionStart: "2026-04-23T17:00:00Z", + pid: 1, + }); + a._locate = async () => FIX("gemini-jsonl-basic.jsonl"); + const snap = await a.scanActivity(); + assert.equal(snap.count, 1, "one tool call across the conversation"); + assert.equal(snap.activityState, "idle", "no calls within RECENT_CALL_MS → idle"); +}); + +test("jsonl $set: metadata merge does not lose messages", async () => { + const a = new GeminiAdapter({ + cwd: "/irrelevant", + sessionStart: "2026-04-23T17:00:00Z", + pid: 1, + }); + a._locate = async () => FIX("gemini-jsonl-set.jsonl"); + await a.scanActivity(); + // White-box: inspect the materialized ConversationRecord directly. + assert.equal(a._parsedCache.messages.length, 2); + assert.equal(a._parsedCache.summary, "first turn done"); + assert.equal(a._parsedCache.lastUpdated, "2026-04-23T17:05:00Z"); +}); + +test("jsonl $rewindTo: drops the named message and everything after, then continues", async () => { + const a = new GeminiAdapter({ + cwd: "/irrelevant", + sessionStart: "2026-04-23T17:00:00Z", + pid: 1, + }); + a._locate = async () => FIX("gemini-jsonl-rewind.jsonl"); + await a.scanActivity(); + const ids = a._parsedCache.messages.map((m) => m.id); + assert.deepEqual(ids, ["m1", "m2", "m3b"], "m3 dropped, m3b appended after the rewind"); +}); + +test("jsonl malformed line: skipped, surrounding lines still parse", async () => { + const a = new GeminiAdapter({ + cwd: "/irrelevant", + sessionStart: "2026-04-23T17:00:00Z", + pid: 1, + }); + a._locate = async () => FIX("gemini-jsonl-malformed.jsonl"); + await a.scanActivity(); + const ids = a._parsedCache.messages.map((m) => m.id); + assert.deepEqual(ids, ["m1", "m2"], "garbage line skipped, m2 still picked up"); +}); + +test("jsonl partial trailing line: prior records survive", async () => { + const full = readFileSync(FIX("gemini-jsonl-rewind.jsonl"), "utf8"); + const truncated = full.slice(0, full.length - 30); // chop the tail + const tmp = join(mkdtempSync(join(tmpdir(), "ghs-")), "session-x.jsonl"); + writeFileSync(tmp, truncated); + + const a = new GeminiAdapter({ + cwd: "/irrelevant", + sessionStart: "2026-04-23T17:00:00Z", + pid: 1, + }); + a._locate = async () => tmp; + await a.scanActivity(); + // Don't assert exact count (depends on truncation point); minimum 1 + // earlier message must survive and no exception was thrown. + assert.ok(a._parsedCache.messages.length >= 1, "at least one earlier message survives a mid-write truncation"); +}); diff --git a/lib/host/registry.mjs b/lib/host/registry.mjs index 0d4e62e..55854cb 100644 --- a/lib/host/registry.mjs +++ b/lib/host/registry.mjs @@ -54,7 +54,11 @@ async function probeGeminiDir(cwd) { try { chatFiles = await fsp.readdir(chatsDir); } catch { return { mtime: st.mtimeMs }; } let latest = 0; for (const f of chatFiles) { - if (!f.endsWith(".json")) continue; + // Match both legacy .json (Gemini ≤0.38) and append-only .jsonl + // (Gemini ≥0.39, per google-gemini/gemini-cli#23749). Without the + // .jsonl branch, fresh ≥0.39 installs would fall back to the less + // accurate tmp-dir mtime in cross-host tie-breaks. + if (!f.endsWith(".json") && !f.endsWith(".jsonl")) continue; try { const fst = await fsp.stat(path.join(chatsDir, f)); if (fst.mtimeMs > latest) latest = fst.mtimeMs;