mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
iframe mounted across toggle and cached height
This commit is contained in:
parent
941df07811
commit
55c16af04c
6 changed files with 89 additions and 14 deletions
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue