iframe mounted across toggle and cached height

This commit is contained in:
Arjun 2026-05-13 18:06:33 +05:30
parent 941df07811
commit 55c16af04c
6 changed files with 89 additions and 14 deletions

View file

@ -47,7 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { fetchThreadSnapshot, listRecentThreadIds, listCachedThreads } from '@x/core/dist/knowledge/sync_gmail.js';
import { fetchThreadSnapshot, listRecentThreadIds, listCachedThreads, saveMessageBodyHeight } from '@x/core/dist/knowledge/sync_gmail.js';
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
@ -506,6 +506,10 @@ export function setupIpcHandlers() {
'gmail:listCachedThreads': async (_event, args) => {
return { threads: listCachedThreads(args.daysAgo ?? 2) };
},
'gmail:saveMessageHeight': async (_event, args) => {
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
return {};
},
'mcp:listTools': async (_event, args) => {
return mcpCore.listTools(args.serverName, args.cursor);
},

View file

@ -323,6 +323,10 @@
box-shadow: inset 2px 0 0 var(--gm-accent);
}
.gmail-detail-hidden {
display: none;
}
.gmail-detail-toolbar {
display: flex;
align-items: center;

View file

@ -110,10 +110,12 @@ function buildEmailDocument(html: string): string {
</head><body>${html}</body></html>`
}
function MessageBody({ message }: { message: GmailThreadMessage }) {
function MessageBody({ message, threadId }: { message: GmailThreadMessage; threadId: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const observerRef = useRef<ResizeObserver | null>(null)
const [height, setHeight] = useState(80)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSavedHeightRef = useRef<number>(message.bodyHeight ?? 0)
const [height, setHeight] = useState(message.bodyHeight ?? 80)
const srcDoc = useMemo(() => {
if (message.bodyHtml && message.bodyHtml.trim()) {
@ -130,6 +132,17 @@ function MessageBody({ message }: { message: GmailThreadMessage }) {
const measure = () => {
const next = Math.max(40, doc.documentElement.scrollHeight)
setHeight((current) => (current === next ? current : next))
if (!message.id) return
if (Math.abs(next - lastSavedHeightRef.current) < 4) return
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => {
lastSavedHeightRef.current = next
void window.ipc.invoke('gmail:saveMessageHeight', {
threadId,
messageId: message.id!,
height: next,
}).catch(() => {})
}, 500)
}
measure()
observerRef.current?.disconnect()
@ -137,9 +150,12 @@ function MessageBody({ message }: { message: GmailThreadMessage }) {
observerRef.current = new ResizeObserver(measure)
observerRef.current.observe(doc.body)
}
}, [])
}, [message.id, threadId])
useEffect(() => () => observerRef.current?.disconnect(), [])
useEffect(() => () => {
observerRef.current?.disconnect()
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
}, [])
return (
<iframe
@ -232,14 +248,16 @@ function ComposeBox({
function ThreadDetail({
thread,
onClose,
hidden,
}: {
thread: GmailThread
onClose: () => void
hidden?: boolean
}) {
const [composeMode, setComposeMode] = useState<ComposeMode | null>(null)
return (
<div className="gmail-detail gmail-detail-inline">
<div className={cn('gmail-detail gmail-detail-inline', hidden && 'gmail-detail-hidden')}>
<div className="gmail-detail-toolbar">
<div className="gmail-thread-subject-inline">{thread.subject || '(No subject)'}</div>
<button type="button" className="gmail-icon-button" onClick={onClose} aria-label="Close thread">
@ -265,7 +283,7 @@ function ThreadDetail({
<div className="gmail-message-date">{formatFullDate(message.date)}</div>
</div>
<div className="gmail-message-to">to {message.to || 'me'}</div>
<MessageBody message={message} />
<MessageBody message={message} threadId={thread.threadId} />
</div>
</div>
)
@ -295,13 +313,29 @@ function ThreadDetail({
)
}
const MAX_KEPT_OPEN = 5
export function EmailView() {
const [threads, setThreads] = useState<GmailThread[]>([])
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
const [openedThreadIds, setOpenedThreadIds] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const toggleThread = useCallback((threadId: string) => {
setSelectedThreadId((current) => {
const next = current === threadId ? null : threadId
if (next) {
setOpenedThreadIds((prev) => {
const without = prev.filter((id) => id !== next)
return [...without, next].slice(-MAX_KEPT_OPEN)
})
}
return next
})
}, [])
const loadThreads = useCallback(async () => {
setError(null)
let hasCachedContent = false
@ -340,8 +374,10 @@ export function EmailView() {
return (Number.isNaN(bDate) ? 0 : bDate) - (Number.isNaN(aDate) ? 0 : aDate)
})
const liveIds = new Set(nextThreads.map((t) => t.threadId))
setThreads(nextThreads)
setSelectedThreadId(current => current && nextThreads.some(thread => thread.threadId === current) ? current : null)
setSelectedThreadId(current => current && liveIds.has(current) ? current : null)
setOpenedThreadIds((prev) => prev.filter((id) => liveIds.has(id)))
} catch (err) {
if (hasCachedContent) {
console.warn('[Gmail] background refresh failed; keeping cached view:', err)
@ -404,12 +440,13 @@ export function EmailView() {
const latest = latestMessage(thread)
const isSelected = thread.threadId === selectedThreadId
const isUnread = thread.unread === true
const isMounted = openedThreadIds.includes(thread.threadId)
return (
<div key={thread.threadId} className="gmail-row-group">
<button
type="button"
className={cn('gmail-row', isSelected && 'gmail-row-selected', isUnread && 'gmail-row-unread')}
onClick={() => setSelectedThreadId(isSelected ? null : thread.threadId)}
onClick={() => toggleThread(thread.threadId)}
>
<span className="gmail-row-dot" aria-hidden />
<span className="gmail-row-sender">{extractName(latest?.from || thread.from)}</span>
@ -419,10 +456,11 @@ export function EmailView() {
</span>
<span className="gmail-row-date">{formatInboxTime(latest?.date || thread.date)}</span>
</button>
{isSelected && (
{isMounted && (
<ThreadDetail
thread={thread}
onClose={() => setSelectedThreadId(null)}
hidden={!isSelected}
/>
)}
</div>

View file

@ -50,6 +50,20 @@ function writeCachedSnapshot(threadId: string, historyId: string, snapshot: Gmai
}
}
export function saveMessageBodyHeight(threadId: string, messageId: string, height: number): void {
const cached = readCachedSnapshot(threadId);
if (!cached) return;
const message = cached.snapshot.messages.find((m) => m.id === messageId);
if (!message) return;
if (message.bodyHeight === height) return;
message.bodyHeight = height;
try {
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
} catch (err) {
console.warn(`[Gmail cache] height write failed for ${threadId}/${messageId}:`, err);
}
}
interface SyncedThread {
threadId: string;
markdown: string;
@ -76,6 +90,7 @@ export interface GmailThreadSnapshot {
body?: string;
bodyHtml?: string;
unread?: boolean;
bodyHeight?: number;
}>;
}
@ -311,10 +326,14 @@ export async function listRecentThreadIds(daysAgo: number = 2): Promise<RecentTh
}
export async function fetchThreadSnapshot(threadId: string, expectedHistoryId?: string): Promise<GmailThreadSnapshot | null> {
if (expectedHistoryId) {
const cached = readCachedSnapshot(threadId);
if (cached && cached.historyId === expectedHistoryId) {
return cached.snapshot;
const cached = readCachedSnapshot(threadId);
if (expectedHistoryId && cached && cached.historyId === expectedHistoryId) {
return cached.snapshot;
}
const heightCarryover = new Map<string, number>();
if (cached) {
for (const m of cached.snapshot.messages) {
if (m.id && typeof m.bodyHeight === 'number') heightCarryover.set(m.id, m.bodyHeight);
}
}
@ -351,6 +370,7 @@ export async function fetchThreadSnapshot(threadId: string, expectedHistoryId?:
body,
bodyHtml,
unread: msg.labelIds?.includes('UNREAD') ?? false,
bodyHeight: msg.id ? heightCarryover.get(msg.id) : undefined,
};
}));

View file

@ -112,6 +112,7 @@ export const GmailThreadMessageSchema = z.object({
body: z.string().optional(),
bodyHtml: z.string().optional(),
unread: z.boolean().optional(),
bodyHeight: z.number().int().positive().optional(),
});
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;

View file

@ -154,6 +154,14 @@ const ipcSchemas = {
threads: z.array(GmailThreadSchema),
}),
},
'gmail:saveMessageHeight': {
req: z.object({
threadId: z.string().min(1),
messageId: z.string().min(1),
height: z.number().int().positive(),
}),
res: z.object({}),
},
'mcp:listTools': {
req: z.object({
serverName: z.string(),