Auto-scroll component for websocket/grpc/sse

This commit is contained in:
Gregory Schier
2025-02-03 07:05:14 -08:00
parent be0ef7afce
commit 17dc1991f1
11 changed files with 208 additions and 137 deletions

View File

@@ -3,7 +3,7 @@ use crate::error::Result;
use crate::manager::WebsocketManager; use crate::manager::WebsocketManager;
use crate::render::render_request; use crate::render::render_request;
use chrono::Utc; use chrono::Utc;
use log::info; use log::{info, warn};
use std::str::FromStr; use std::str::FromStr;
use tauri::http::{HeaderMap, HeaderName}; use tauri::http::{HeaderMap, HeaderName};
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow}; use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
@@ -143,7 +143,9 @@ pub(crate) async fn close<R: Runtime>(
.ok_or(GenericError("WebSocket Request not found".to_string()))?; .ok_or(GenericError("WebSocket Request not found".to_string()))?;
let mut ws_manager = ws_manager.lock().await; let mut ws_manager = ws_manager.lock().await;
ws_manager.send(&connection.id, Message::Close(None)).await?; if let Err(e) = ws_manager.send(&connection.id, Message::Close(None)).await {
warn!("Failed to close WebSocket connection: {e:?}");
};
upsert_websocket_event( upsert_websocket_event(
&window, &window,
WebsocketEvent { WebsocketEvent {

View File

@@ -9,14 +9,16 @@ interface Props {
export function EmptyStateText({ children, className }: Props) { export function EmptyStateText({ children, className }: Props) {
return ( return (
<div <div className="w-full h-full pb-2">
className={classNames( <div
className, className={classNames(
'rounded-lg border border-dashed border-border-subtle', className,
'h-full py-2 text-text-subtlest flex items-center justify-center italic', 'rounded-lg border border-dashed border-border-subtle',
)} 'h-full py-2 text-text-subtlest flex items-center justify-center italic',
> )}
{children} >
{children}
</div>
</div> </div>
); );
} }

View File

@@ -2,11 +2,12 @@ import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { format } from 'date-fns'; import { format } from 'date-fns';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import React, { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useCopy } from '../hooks/useCopy'; import { useCopy } from '../hooks/useCopy';
import { useGrpcEvents } from '../hooks/useGrpcEvents'; import { useGrpcEvents } from '../hooks/useGrpcEvents';
import { usePinnedGrpcConnection } from '../hooks/usePinnedGrpcConnection'; import { usePinnedGrpcConnection } from '../hooks/usePinnedGrpcConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
@@ -80,24 +81,25 @@ export function GrpcConnectionMessagesPane({ style, methodType, activeRequest }:
/> />
</div> </div>
</HStack> </HStack>
<div className="overflow-y-auto h-full"> {activeConnection.error && (
{activeConnection.error && ( <Banner color="danger" className="m-3">
<Banner color="danger" className="m-3"> {activeConnection.error}
{activeConnection.error} </Banner>
</Banner> )}
)} <AutoScroller
{...events.map((e) => ( data={events}
render={(event) => (
<EventRow <EventRow
key={e.id} key={event.id}
event={e} event={event}
isActive={e.id === activeEventId} isActive={event.id === activeEventId}
onClick={() => { onClick={() => {
if (e.id === activeEventId) setActiveEventId(null); if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(e.id); else setActiveEventId(event.id);
}} }}
/> />
))} )}
</div> />
</div> </div>
) )
} }
@@ -195,14 +197,16 @@ function EventRow({
event: GrpcEvent; event: GrpcEvent;
}) { }) {
const { eventType, status, createdAt, content, error } = event; const { eventType, status, createdAt, content, error } = event;
const ref = useRef<HTMLDivElement>(null);
return ( return (
<div className="px-1"> <div className="px-1" ref={ref}>
<button <button
onClick={onClick} onClick={onClick}
className={classNames( className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left', 'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded', 'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-highlight !text-text', isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text', 'text-text-subtle hover:text',
)} )}
> >

View File

@@ -456,7 +456,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`other.${activeRequest.id}`} stateKey={`other.${activeRequest.id}`}
/> />
) : ( ) : (
<EmptyStateText>Empty Body</EmptyStateText> <EmptyStateText>No Body</EmptyStateText>
)} )}
</TabContent> </TabContent>
<TabContent value={TAB_DESCRIPTION}> <TabContent value={TAB_DESCRIPTION}>

View File

@@ -162,9 +162,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<Icon size="xl" spin icon="refresh" className="text-text-subtlest" /> <Icon size="xl" spin icon="refresh" className="text-text-subtlest" />
</EmptyStateText> </EmptyStateText>
) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? ( ) : activeResponse.state === 'closed' && activeResponse.contentLength === 0 ? (
<div className="pb-2 h-full"> <EmptyStateText>Empty </EmptyStateText>
<EmptyStateText>Empty Body</EmptyStateText>
</div>
) : contentType?.match(/^text\/event-stream$/i) && viewMode === 'pretty' ? ( ) : contentType?.match(/^text\/event-stream$/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} /> <EventStreamViewer response={activeResponse} />
) : contentType?.match(/^image\/svg/) ? ( ) : contentType?.match(/^image\/svg/) ? (

View File

@@ -2,13 +2,14 @@ import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models'
import classNames from 'classnames'; import classNames from 'classnames';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { hexy } from 'hexy'; import { hexy } from 'hexy';
import React, { useMemo, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import { useCopy } from '../hooks/useCopy'; import { useCopy } from '../hooks/useCopy';
import { useFormatText } from '../hooks/useFormatText'; import { useFormatText } from '../hooks/useFormatText';
import { usePinnedWebsocketConnection } from '../hooks/usePinnedWebsocketConnection'; import { usePinnedWebsocketConnection } from '../hooks/usePinnedWebsocketConnection';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { useWebsocketEvents } from '../hooks/useWebsocketEvents'; import { useWebsocketEvents } from '../hooks/useWebsocketEvents';
import { languageFromContentType } from '../lib/contentType'; import { languageFromContentType } from '../lib/contentType';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/Editor'; import { Editor } from './core/Editor/Editor';
@@ -55,7 +56,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
}, [activeEvent?.message, hexDump]); }, [activeEvent?.message, hexDump]);
const language = languageFromContentType(null, message); const language = languageFromContentType(null, message);
const formattedContent = useFormatText({ language, text: message, pretty: true }); const formattedMessage = useFormatText({ language, text: message, pretty: true });
const copy = useCopy(); const copy = useCopy();
return ( return (
@@ -76,32 +77,33 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
<span>&bull;</span> <span>&bull;</span>
<span>{events.length} Messages</span> <span>{events.length} Messages</span>
</HStack> </HStack>
<div className="ml-auto"> <HStack space={0.5} className="ml-auto">
<RecentWebsocketConnectionsDropdown <RecentWebsocketConnectionsDropdown
connections={connections} connections={connections}
activeConnection={activeConnection} activeConnection={activeConnection}
onPinnedConnectionId={setPinnedConnectionId} onPinnedConnectionId={setPinnedConnectionId}
/> />
</div> </HStack>
</HStack> </HStack>
<div className="overflow-y-auto h-full"> {activeConnection.error && (
{activeConnection.error && ( <Banner color="danger" className="m-3">
<Banner color="danger" className="m-3"> {activeConnection.error}
{activeConnection.error} </Banner>
</Banner> )}
)} <AutoScroller
{...events.map((e) => ( data={events}
render={(event) => (
<EventRow <EventRow
key={e.id} key={event.id}
event={e} event={event}
isActive={e.id === activeEventId} isActive={event.id === activeEventId}
onClick={() => { onClick={() => {
if (e.id === activeEventId) setActiveEventId(null); if (event.id === activeEventId) setActiveEventId(null);
else setActiveEventId(e.id); else setActiveEventId(event.id);
}} }}
/> />
))} )}
</div> />
</div> </div>
) )
} }
@@ -113,30 +115,32 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
<Separator /> <Separator />
</div> </div>
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]"> <div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center"> <div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold"> <div className="font-semibold">
{activeEvent.messageType === 'close' {activeEvent.messageType === 'close'
? 'Connection Closed' ? 'Connection Closed'
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`} : `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
</div> </div>
<HStack space={1}> {message != '' && (
<Button <HStack space={1}>
variant="border" <Button
size="xs" variant="border"
onClick={() => { size="xs"
if (activeEventId == null) return; onClick={() => {
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump }); if (activeEventId == null) return;
}} setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
> }}
{hexDump ? 'Show Message' : 'Show Hexdump'} >
</Button> {hexDump ? 'Show Message' : 'Show Hexdump'}
<IconButton </Button>
title="Copy message" <IconButton
icon="copy" title="Copy message"
size="xs" icon="copy"
onClick={() => copy(message)} size="xs"
/> onClick={() => copy(formattedMessage.data ?? '')}
</HStack> />
</HStack>
)}
</div> </div>
{!showLarge && activeEvent.message.length > 1000 * 1000 ? ( {!showLarge && activeEvent.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest"> <VStack space={2} className="italic text-text-subtlest">
@@ -164,7 +168,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
) : ( ) : (
<Editor <Editor
language={language} language={language}
defaultValue={formattedContent.data ?? ''} defaultValue={formattedMessage.data ?? ''}
wrapLines={false} wrapLines={false}
readOnly={true} readOnly={true}
stateKey={null} stateKey={null}
@@ -188,17 +192,19 @@ function EventRow({
event: WebsocketEvent; event: WebsocketEvent;
}) { }) {
const { createdAt, message: messageBytes, isServer, messageType } = event; const { createdAt, message: messageBytes, isServer, messageType } = event;
const ref = useRef<HTMLDivElement>(null);
const message = messageBytes const message = messageBytes
? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes)) ? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes))
: ''; : '';
return ( return (
<div className="px-1"> <div className="px-1" ref={ref}>
<button <button
onClick={onClick} onClick={onClick}
className={classNames( className={classNames(
'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left', 'w-full grid grid-cols-[auto_minmax(0,3fr)_auto] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded', 'px-1.5 h-xs font-mono cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-highlight !text-text', isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text', 'text-text-subtle hover:text',
)} )}
> >
@@ -215,9 +221,13 @@ function EventRow({
} }
/> />
<div className={classNames('w-full truncate text-xs')}> <div className={classNames('w-full truncate text-xs')}>
{messageType === 'close' {messageType === 'close' ? (
? 'Connection closed by ' + (isServer ? 'server' : 'client') 'Connection closed by ' + (isServer ? 'server' : 'client')
: message.slice(0, 1000)} ) : message === '' ? (
<em className="italic text-text-subtlest">No content</em>
) : (
message.slice(0, 1000)
)}
{/*{error && <span className="text-warning"> ({error})</span>}*/} {/*{error && <span className="text-warning"> ({error})</span>}*/}
</div> </div>
<div className={classNames('opacity-50 text-xs')}> <div className={classNames('opacity-50 text-xs')}>

View File

@@ -0,0 +1,89 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import type { ReactElement, UIEvent } from 'react';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { IconButton } from './IconButton';
interface Props<T> {
data: T[];
render: (item: T, index: number) => ReactElement<HTMLElement>;
}
export function AutoScroller<T>({ data, render }: Props<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState<boolean>(true);
// The virtualizer
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 27, // react-virtual requires a height, so we'll give it one
});
// Scroll to new items
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
// Set auto-scroll when container is scrolled
const pixelsFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);
const newAutoScroll = pixelsFromBottom <= 0;
if (newAutoScroll !== autoScroll) {
setAutoScroll(newAutoScroll);
}
},
[autoScroll],
);
// Scroll to bottom on count change
useLayoutEffect(() => {
if (!autoScroll) return;
const el = containerRef.current;
if (el == null) return;
el.scrollTop = el.scrollHeight;
}, [autoScroll, data.length]);
return (
<div className="h-full w-full relative">
{!autoScroll && (
<div className="absolute bottom-0 right-0 m-2">
<IconButton
title="Lock scroll to bottom"
icon="arrow_down"
size="sm"
iconSize="md"
variant="border"
className={'!bg-surface z-10'}
onClick={() => setAutoScroll((v) => !v)}
/>
</div>
)}
<div ref={containerRef} className="h-full w-full overflow-y-auto" onScroll={handleScroll}>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
// height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{render(data[virtualItem.index]!, virtualItem.index)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -61,6 +61,7 @@ const icons = {
keyboard: lucide.KeyboardIcon, keyboard: lucide.KeyboardIcon,
left_panel_hidden: lucide.PanelLeftOpenIcon, left_panel_hidden: lucide.PanelLeftOpenIcon,
left_panel_visible: lucide.PanelLeftCloseIcon, left_panel_visible: lucide.PanelLeftCloseIcon,
lock: lucide.LockIcon,
magic_wand: lucide.Wand2Icon, magic_wand: lucide.Wand2Icon,
minus: lucide.MinusIcon, minus: lucide.MinusIcon,
minus_circle: lucide.MinusCircleIcon, minus_circle: lucide.MinusCircleIcon,

View File

@@ -84,7 +84,7 @@ export const JsonAttributeTree = ({
}, [attrValue, attrKeyJsonPath, isExpanded, depth]); }, [attrValue, attrKeyJsonPath, isExpanded, depth]);
const labelEl = ( const labelEl = (
<span className={classNames(labelClassName, 'select-text group-hover:text-text-subtle')}> <span className={classNames(labelClassName, 'cursor-text select-text group-hover:text-text-subtle')}>
{label} {label}
</span> </span>
); );
@@ -115,7 +115,7 @@ export const JsonAttributeTree = ({
</button> </button>
) : ( ) : (
<> <>
<span className="text-primary mr-1.5 pl-4 whitespace-nowrap select-text"> <span className="text-primary mr-1.5 pl-4 whitespace-nowrap cursor-text select-text">
{attrKey}: {attrKey}:
</span> </span>
{labelEl} {labelEl}

View File

@@ -1,12 +1,11 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import type { HttpResponse } from '@yaakapp-internal/models'; import type { HttpResponse } from '@yaakapp-internal/models';
import type { ServerSentEvent } from '@yaakapp-internal/sse'; import type { ServerSentEvent } from '@yaakapp-internal/sse';
import classNames from 'classnames'; import classNames from 'classnames';
import { motion } from 'framer-motion'; import React, { Fragment, useMemo, useState } from 'react';
import React, { Fragment, useMemo, useRef, useState } from 'react';
import { useFormatText } from '../../hooks/useFormatText'; import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource'; import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
import { isJSON } from '../../lib/contentType'; import { isJSON } from '../../lib/contentType';
import { AutoScroller } from '../core/AutoScroller';
import { Button } from '../core/Button'; import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor/Editor'; import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/Editor'; import { Editor } from '../core/Editor/Editor';
@@ -52,7 +51,7 @@ function ActualEventStreamViewer({ response }: Props) {
defaultRatio={0.4} defaultRatio={0.4}
minHeightPx={20} minHeightPx={20}
firstSlot={() => ( firstSlot={() => (
<EventStreamEventsVirtual <EventStreamEvents
events={events.data ?? []} events={events.data ?? []}
activeEventIndex={activeEventIndex} activeEventIndex={activeEventIndex}
setActiveEventIndex={setActiveEventIndex} setActiveEventIndex={setActiveEventIndex}
@@ -66,7 +65,7 @@ function ActualEventStreamViewer({ response }: Props) {
<Separator /> <Separator />
</div> </div>
<div className="pl-2 overflow-y-auto"> <div className="pl-2 overflow-y-auto">
<HStack space={1.5} className="mb-2 select-text cursor-text font-semibold"> <HStack space={1.5} className="mb-2 font-semibold">
<EventLabels <EventLabels
className="text-sm" className="text-sm"
event={activeEvent} event={activeEvent}
@@ -113,7 +112,7 @@ function FormattedEditor({ text, language }: { text: string; language: EditorPro
return <Editor readOnly defaultValue={formatted.data} language={language} stateKey={null} />; return <Editor readOnly defaultValue={formatted.data} language={language} stateKey={null} />;
} }
function EventStreamEventsVirtual({ function EventStreamEvents({
events, events,
activeEventIndex, activeEventIndex,
setActiveEventIndex, setActiveEventIndex,
@@ -122,53 +121,21 @@ function EventStreamEventsVirtual({
activeEventIndex: number | null; activeEventIndex: number | null;
setActiveEventIndex: (eventId: number | null) => void; setActiveEventIndex: (eventId: number | null) => void;
}) { }) {
// The scrollable element for your list
const parentRef = useRef<HTMLDivElement>(null);
// The virtualizer
const rowVirtualizer = useVirtualizer({
count: events.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28, // react-virtual requires a height, so we'll give it one
});
return ( return (
<div ref={parentRef} className="overflow-y-auto"> <AutoScroller
<div data={events}
style={{ render={(event, i) => (
height: `${rowVirtualizer.getTotalSize()}px`, <EventStreamEvent
width: '100%', event={event}
position: 'relative', isActive={i === activeEventIndex}
}} index={i}
> onClick={() => {
{rowVirtualizer.getVirtualItems().map((virtualItem) => { if (i === activeEventIndex) setActiveEventIndex(null);
const event = events[virtualItem.index]!; else setActiveEventIndex(i);
return ( }}
<div />
key={virtualItem.key} )}
style={{ />
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<EventStreamEvent
event={event}
isActive={virtualItem.index === activeEventIndex}
index={virtualItem.index}
onClick={() => {
if (virtualItem.index === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(virtualItem.index);
}}
/>
</div>
);
})}
</div>
</div>
); );
} }
@@ -186,22 +153,20 @@ function EventStreamEvent({
index: number; index: number;
}) { }) {
return ( return (
<motion.button <button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onClick={onClick} onClick={onClick}
className={classNames( className={classNames(
className, className,
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left', 'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded', '-mx-1.5 px-1.5 h-xs font-mono group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-highlight !text-text', isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text', 'text-text-subtle hover:text',
)} )}
> >
<Icon className={classNames('text-info')} title="Server Message" icon="arrow_big_down_dash" /> <Icon className={classNames('text-info')} title="Server Message" icon="arrow_big_down_dash" />
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} /> <EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div> <div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
</motion.button> </button>
); );
} }

View File

@@ -25,7 +25,7 @@ function initForWorkspace(workspaceId: string, syncDir: string | null) {
const debouncedSync = debounce(() => { const debouncedSync = debounce(() => {
if (syncDir == null) return; if (syncDir == null) return;
syncWorkspace.mutate({ workspaceId, syncDir }); syncWorkspace.mutate({ workspaceId, syncDir });
}); }, 1000);
// Sync on model upsert // Sync on model upsert
const unsubUpsertedModels = listenToTauriEvent<ModelPayload>('upserted_model', (p) => { const unsubUpsertedModels = listenToTauriEvent<ModelPayload>('upserted_model', (p) => {