Skip to content
Merged
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
25 changes: 24 additions & 1 deletion data/sessions.html
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,17 @@ <h3>Daemon</h3>
// 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);
Expand Down Expand Up @@ -1153,6 +1164,18 @@ <h3>Daemon</h3>
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 = "";
Expand Down Expand Up @@ -1249,7 +1272,7 @@ <h3>Daemon</h3>
}

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([
Expand Down
2 changes: 1 addition & 1 deletion index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions lib/host/__fixtures__/gemini-jsonl-basic.jsonl
Original file line number Diff line number Diff line change
@@ -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"}}
4 changes: 4 additions & 0 deletions lib/host/__fixtures__/gemini-jsonl-malformed.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
6 changes: 6 additions & 0 deletions lib/host/__fixtures__/gemini-jsonl-rewind.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
4 changes: 4 additions & 0 deletions lib/host/__fixtures__/gemini-jsonl-set.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
13 changes: 13 additions & 0 deletions lib/host/__fixtures__/gemini-legacy.json
Original file line number Diff line number Diff line change
@@ -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" }
] }
]
}
89 changes: 78 additions & 11 deletions lib/host/gemini.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
// Gemini CLI adapter. Gemini stores each session as a single JSON file at
// ~/.gemini/tmp/<cwd-basename-or-hash>/chats/session-<iso>-<short>.json
// Gemini CLI adapter. Gemini stores each session as a chat file at
// ~/.gemini/tmp/<cwd-encoded-or-hash>/chats/session-<iso>-<short>.{json,jsonl}
// The `.project_root` file in each tmp/<dir> 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:"<id>"}). 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, ... } ]
Expand Down Expand Up @@ -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: "<message-id>"})
// 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";
Expand All @@ -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
}
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
Expand Down
102 changes: 102 additions & 0 deletions lib/host/gemini.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
6 changes: 5 additions & 1 deletion lib/host/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down