From f27088f0cc7ec9e83fb28ff50bf2468a79eda5ec Mon Sep 17 00:00:00 2001 From: bbsngg Date: Fri, 17 Apr 2026 14:56:53 -0400 Subject: [PATCH] =?UTF-8?q?fix(gemini):=20sync=20TUI=E2=86=94chat=20and=20?= =?UTF-8?q?restore=20session=20titles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - persistGeminiSessionMetadata now writes null instead of the "Untitled Session" placeholder; buildGeminiSessionsIndex treats legacy rows with that placeholder as empty so firstMessageText fallback can run. - Same placeholder fix for nano-claude-code's "Nano Claude Code Session". - Resolve Gemini session UUID → --list-sessions index before passing to `gemini --resume` (the CLI doesn't accept UUIDs). - Ingest ~/.gemini/tmp/{hash}/chats/session-*.json (live TUI state); getSessionMessages prefers this blob when newer than the committed jsonl so chat mirrors what the TUI is displaying. - New gemini-tmp chokidar watcher resolves tmp/chats changes back to a session UUID and bypasses the identical-snapshot shortcut so the chat page's existing {uuid}.jsonl refetch gate still fires. - Relax the useProjectsState gate that required a 2-part changedFile path, unblocking Gemini refetch triggers. - Shell renders a yellow advisory banner when resuming a Gemini session, since the TUI can't ingest chat-originated writes without re-resume. Co-Authored-By: Claude Opus 4.7 --- server/gemini-cli.js | 8 +- server/index.js | 86 ++++++++++++++++--- server/nano-claude-code.js | 8 +- server/projects.js | 150 +++++++++++++++++++++++++++++++++- src/components/Shell.jsx | 13 +++ src/hooks/useProjectsState.ts | 16 ++-- 6 files changed, 258 insertions(+), 23 deletions(-) diff --git a/server/gemini-cli.js b/server/gemini-cli.js index e587fe1d..6239a094 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -88,11 +88,17 @@ async function persistGeminiSessionMetadata(sessionId, projectPath, sessionMode) sessionId, encodeProjectPath(projectPath), 'gemini', - 'Untitled Session', + null, new Date().toISOString(), 0, { sessionMode: sessionMode || 'research' }, ); + // upsertSession preserves existing display_name when incoming is null, so + // stale "Untitled Session" rows from older builds need an explicit clear. + const existing = sessionDb.getSessionById(sessionId); + if (existing && existing.display_name === 'Untitled Session') { + sessionDb.updateSessionName(sessionId, null); + } } catch (error) { console.warn('[Gemini] Failed to persist session metadata:', error.message); } diff --git a/server/index.js b/server/index.js index 1f861604..5873252f 100755 --- a/server/index.js +++ b/server/index.js @@ -37,12 +37,14 @@ import os from 'os'; import http from 'http'; import cors from 'cors'; import { promises as fsPromises } from 'fs'; -import { spawn } from 'child_process'; +import { spawn, execFile } from 'child_process'; +import { promisify } from 'util'; +const execFileAsync = promisify(execFile); import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; -import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; +import { getProjects, getTrashedProjects, getSessions, getSessionMessages, renameProject, renameSession, deleteSession, deleteProject, restoreProject, deleteTrashedProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, readGeminiTmpChatSessionId } from './projects.js'; import { getProjectTokenUsageSummary } from './project-token-usage.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getClaudeSDKSessionStartTime, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getCursorSessionStartTime, getActiveCursorSessions } from './cursor-cli.js'; @@ -89,12 +91,45 @@ import { import { buildCodexTokenUsageFromJsonl } from './utils/sessionTokenUsage.js'; import { getNanoDrClawSessionsRoot } from './nanoSessionPaths.js'; +// Gemini CLI's --resume flag only accepts "latest" or a numeric index from +// --list-sessions, not the session UUID. This helper maps UUID → index so the +// shell can actually resume the session the chat page is pointing at. +const GEMINI_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +async function resolveGeminiSessionIndex(sessionUuid, cwd) { + if (!GEMINI_UUID_PATTERN.test(sessionUuid || '')) { + return null; + } + try { + const { stdout } = await execFileAsync('gemini', ['--list-sessions'], { + cwd, + timeout: 10_000, + env: process.env, + }); + const targetSuffix = `[${sessionUuid}]`; + for (const line of stdout.split('\n')) { + const trimmed = line.trim(); + if (trimmed.endsWith(targetSuffix)) { + const match = trimmed.match(/^(\d+)\./); + if (match) return match[1]; + } + } + return null; + } catch (err) { + console.warn('[Gemini] Failed to resolve session index:', err.message); + return null; + } +} + // File system watchers for provider project/session folders const PROVIDER_WATCH_PATHS = [ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') }, { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') }, { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }, { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'sessions') }, + // Gemini TUI rewrites live conversation state under ~/.gemini/tmp/{hash}/chats/ + // on every turn. Watching it lets the chat page mirror in-shell activity + // before the committed .jsonl flush happens. + { provider: 'gemini-tmp', rootPath: path.join(os.homedir(), '.gemini', 'tmp') }, { provider: 'nano', rootPath: getNanoDrClawSessionsRoot() }, ]; const WATCHER_IGNORED_PATTERNS = [ @@ -125,6 +160,11 @@ function shouldProcessProjectsWatcherEvent(eventType, filePath, provider) { return normalized.endsWith('.jsonl'); } + if (provider === 'gemini-tmp') { + const base = path.basename(normalized); + return base.startsWith('session-') && base.endsWith('.json'); + } + if (provider === 'cursor') { return ( normalized.endsWith('.db') || @@ -205,8 +245,28 @@ async function setupProjectsWatcher() { const updatedProjects = await getProjects(); const updateSignature = JSON.stringify(updatedProjects); - // Skip broadcasting identical snapshots - if (updateSignature === lastProjectsUpdateSignature) { + // For gemini-tmp we need to resolve the filePath to a sessionId so + // the frontend's existing "{uuid}.jsonl" gate can fire. We also skip + // the identical-snapshot shortcut below because tmp/chats churn + // does not change the project index, yet still needs to reach chat. + let broadcastChangedFile = path.relative(rootPath, filePath); + let broadcastProvider = provider; + let forceBroadcast = false; + if (provider === 'gemini-tmp') { + const resolvedSessionId = await readGeminiTmpChatSessionId(filePath); + if (resolvedSessionId) { + broadcastChangedFile = `${resolvedSessionId}.jsonl`; + broadcastProvider = 'gemini'; + forceBroadcast = true; + } else { + // Can't resolve — nothing actionable for the client. + return; + } + } + + // Skip broadcasting identical snapshots unless we explicitly need + // to push a tmp/chats notification through. + if (!forceBroadcast && updateSignature === lastProjectsUpdateSignature) { return; } lastProjectsUpdateSignature = updateSignature; @@ -217,8 +277,8 @@ async function setupProjectsWatcher() { projects: updatedProjects, timestamp: new Date().toISOString(), changeType: eventType, - changedFile: path.relative(rootPath, filePath), - watchProvider: provider + changedFile: broadcastChangedFile, + watchProvider: broadcastProvider }); connectedClients.forEach(client => { @@ -2045,15 +2105,21 @@ function handleShellConnection(ws) { } else if (provider === 'gemini') { // Use gemini command const command = initialCommand || 'gemini'; + const resumeIndex = hasSession && sessionId + ? await resolveGeminiSessionIndex(sessionId, projectPath) + : null; + if (hasSession && sessionId && !resumeIndex) { + console.warn(`[Gemini] Could not resolve session ${sessionId} to a --list-sessions index; falling back to a fresh session.`); + } if (os.platform() === 'win32') { - if (hasSession && sessionId) { - shellCommand = `Set-Location -Path "${projectPath}"; gemini --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { gemini }`; + if (resumeIndex) { + shellCommand = `Set-Location -Path "${projectPath}"; gemini --resume ${resumeIndex}; if ($LASTEXITCODE -ne 0) { gemini }`; } else { shellCommand = `Set-Location -Path "${projectPath}"; ${command}`; } } else { - if (hasSession && sessionId) { - shellCommand = `cd "${projectPath}" && gemini --resume ${sessionId} || gemini`; + if (resumeIndex) { + shellCommand = `cd "${projectPath}" && gemini --resume ${resumeIndex} || gemini`; } else { shellCommand = `cd "${projectPath}" && ${command}`; } diff --git a/server/nano-claude-code.js b/server/nano-claude-code.js index 86d51238..31f89ebb 100644 --- a/server/nano-claude-code.js +++ b/server/nano-claude-code.js @@ -64,11 +64,17 @@ async function persistNanoSessionMetadata(sessionId, projectPath, sessionMode) { sessionId, encodeProjectPath(projectPath), 'nano', - 'Nano Claude Code Session', + null, new Date().toISOString(), 0, { sessionMode: sessionMode || 'research', projectPath }, ); + // upsertSession preserves existing display_name when incoming is null, so + // stale placeholder rows from older builds need an explicit clear. + const existing = sessionDb.getSessionById(sessionId); + if (existing && existing.display_name === 'Nano Claude Code Session') { + sessionDb.updateSessionName(sessionId, null); + } } catch (error) { console.warn('[Nano] Failed to persist session metadata:', error.message); } diff --git a/server/projects.js b/server/projects.js index 776e0ff7..ce71d7e2 100755 --- a/server/projects.js +++ b/server/projects.js @@ -1990,6 +1990,117 @@ async function unlinkNanoSessionFilesEverywhere(projectName, sessionId) { return deleted; } +// Gemini TUI writes live conversation state to +// ~/.gemini/tmp/{projectHash}/chats/session-*.json +// on every turn, but only flushes ~/.gemini/sessions/{uuid}.jsonl periodically. +// These helpers ingest the tmp/chats blob so chat views can mirror what the TUI +// is actually showing the user. +const GEMINI_TMP_CHATS_ROOT = path.join(os.homedir(), '.gemini', 'tmp'); + +/** Extract sessionId from a tmp/chats JSON file without loading the full parse into callers. */ +async function readGeminiTmpChatSessionId(filePath) { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw); + const sid = parsed?.sessionId; + return typeof sid === 'string' && sid.length > 0 ? sid : null; + } catch { + return null; + } +} + +/** Locate the tmp/chats JSON blob that matches the given sessionId (scans all project subdirs). */ +async function findGeminiTmpChatFile(sessionId) { + if (!sessionId || typeof sessionId !== 'string') return null; + let projectDirs; + try { + projectDirs = await fs.readdir(GEMINI_TMP_CHATS_ROOT, { withFileTypes: true }); + } catch { + return null; + } + // Filename suffix "session-YYYY-MM-DD...-{first8}.json" narrows the scan. + const shortId = sessionId.slice(0, 8); + for (const entry of projectDirs) { + if (!entry.isDirectory()) continue; + const chatsDir = path.join(GEMINI_TMP_CHATS_ROOT, entry.name, 'chats'); + let chatFiles; + try { + chatFiles = await fs.readdir(chatsDir); + } catch { + continue; + } + for (const fname of chatFiles) { + if (!fname.startsWith('session-') || !fname.endsWith('.json')) continue; + if (shortId && !fname.includes(shortId)) continue; + const full = path.join(chatsDir, fname); + const sid = await readGeminiTmpChatSessionId(full); + if (sid === sessionId) { + let mtime = 0; + try { + const stat = await fs.stat(full); + mtime = stat.mtimeMs; + } catch {} + return { filePath: full, mtime }; + } + } + } + return null; +} + +/** Convert tmp/chats message list into the same shape produced by the jsonl branch. */ +function convertGeminiTmpChatsToMessages(data) { + const rawMessages = Array.isArray(data?.messages) ? data.messages : []; + const out = []; + for (const m of rawMessages) { + const timestamp = m.timestamp || ''; + const kind = m.type; + if (kind === 'user') { + const parts = Array.isArray(m.content) ? m.content : []; + const text = parts.map((p) => (typeof p?.text === 'string' ? p.text : '')).join(''); + out.push({ type: 'message', role: 'user', content: text, timestamp }); + continue; + } + if (kind === 'gemini') { + const content = typeof m.content === 'string' ? m.content : ''; + if (content) { + out.push({ type: 'message', role: 'assistant', content, timestamp }); + } + if (Array.isArray(m.toolCalls)) { + for (const tc of m.toolCalls) { + let toolInput = '{}'; + try { + toolInput = typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args ?? {}); + } catch { + toolInput = '{}'; + } + out.push({ + type: 'tool_use', + timestamp: tc.timestamp || timestamp, + toolName: tc.name || tc.displayName || 'unknown', + toolInput, + toolCallId: tc.id, + }); + // Tool results are nested inside toolCalls[].result[].functionResponse.response + const resultEntries = Array.isArray(tc.result) ? tc.result : []; + for (const r of resultEntries) { + const output = r?.functionResponse?.response?.output; + if (typeof output === 'string' && output.length) { + out.push({ + type: 'tool_result', + output, + tool_call_id: tc.id, + toolCallId: tc.id, + timestamp: tc.timestamp || timestamp, + }); + } + } + } + } + } + } + return out; +} + /** Maps nano-claude-code session.json (title, messages[]) to Dr. Claw chat message shape. */ function nanoSessionJsonToMessages(data) { const rawMessages = Array.isArray(data.messages) ? data.messages : []; @@ -2017,6 +2128,38 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = if (provider === 'gemini') { const geminiSessionFile = path.join(os.homedir(), '.gemini', 'sessions', `${sessionId}.jsonl`); console.log(`[DEBUG] Reading Gemini session file: ${geminiSessionFile}`); + + // Check if the TUI's live tmp/chats blob is more recent than the committed jsonl. + // When it is, prefer the tmp/chats content so chat reflects what the user sees + // in the shell. Otherwise fall through to the jsonl reader below. + const tmpChat = await findGeminiTmpChatFile(sessionId); + let jsonlMtime = 0; + try { + const stat = await fs.stat(geminiSessionFile); + jsonlMtime = stat.mtimeMs; + } catch {} + if (tmpChat && tmpChat.mtime >= jsonlMtime) { + try { + const raw = await fs.readFile(tmpChat.filePath, 'utf-8'); + const data = JSON.parse(raw); + const messages = convertGeminiTmpChatsToMessages(data); + console.log(`[DEBUG] Loaded ${messages.length} messages from Gemini tmp/chats: ${tmpChat.filePath}`); + const total = messages.length; + if (limit === null) return messages; + const startIndex = Math.max(0, total - offset - limit); + const endIndex = total - offset; + return { + messages: messages.slice(startIndex, endIndex), + total, + hasMore: startIndex > 0, + offset, + limit, + }; + } catch (err) { + console.warn(`[Gemini] Failed to read tmp/chats ${tmpChat.filePath}:`, err.message); + } + } + try { await fs.access(geminiSessionFile); const messages = []; @@ -3451,7 +3594,10 @@ async function buildGeminiSessionsIndex() { const indexedMessageCount = Number(indexedSession?.message_count ?? indexedSession?.messageCount ?? 0); const matchedProjectPaths = new Set(); - let explicitTitle = indexedSession?.display_name || null; + const storedDisplayName = indexedSession?.display_name || null; + // Treat the legacy "Untitled Session" placeholder as empty so firstMessageText + // fallback can run for rows seeded by older builds. + let explicitTitle = storedDisplayName === 'Untitled Session' ? null : storedDisplayName; let firstMessageText = null; let messageCount = 0; // Recovery order: DB metadata -> JSONL metadata -> context marker -> high-confidence heuristic -> default. @@ -4371,6 +4517,8 @@ export { reconcileClaudeSessionIndex, reconcileCodexSessionIndex, reconcileGeminiSessionIndex, + readGeminiTmpChatSessionId, + GEMINI_TMP_CHATS_ROOT, reconcileOpenRouterSessionIndex, reconcileLocalGPUSessionIndex, ensureProjectSkillLinks, diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx index 34273c1b..c0d5e3b7 100644 --- a/src/components/Shell.jsx +++ b/src/components/Shell.jsx @@ -191,6 +191,19 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell if (fitAddon.current && terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) { fitAddon.current.fit(); + // Gemini TUI keeps conversation state in memory and never re-reads its + // session file, so messages sent from the chat page won't appear in + // this running TUI. Surface that asymmetry up-front. + if ( + !isPlainShellRef.current && + selectedSessionRef.current && + getPreferredProvider(selectedSessionRef.current) === 'gemini' + ) { + terminal.current.write( + '\x1b[33m[\u26A0 Gemini TUI won\'t see messages sent from the chat page until you exit and re-resume this session]\x1b[0m\r\n' + ); + } + ws.current.send(JSON.stringify({ type: 'init', projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path, diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index dcd7511f..49e620ab 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -545,18 +545,14 @@ export function useProjectsState({ if (projectsMessage.changedFile && selectedSession && selectedProject) { const normalized = projectsMessage.changedFile.replace(/\\/g, '/'); - const changedFileParts = normalized.split('/'); + const filename = normalized.split('/').pop() || ''; + const changedSessionId = filename.replace('.jsonl', ''); - if (changedFileParts.length >= 2) { - const filename = changedFileParts[changedFileParts.length - 1]; - const changedSessionId = filename.replace('.jsonl', ''); + if (changedSessionId && changedSessionId === selectedSession.id) { + const isSessionActive = activeSessions.has(selectedSession.id); - if (changedSessionId === selectedSession.id) { - const isSessionActive = activeSessions.has(selectedSession.id); - - if (!isSessionActive) { - setExternalMessageUpdate((prev) => prev + 1); - } + if (!isSessionActive) { + setExternalMessageUpdate((prev) => prev + 1); } } }