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;