mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
perf(native-chat): preserve historical tool rows while streaming (#19364)
* perf(native-chat): preserve historical tool rows while streaming * perf(native-chat): short-circuit identical rows and lock producer immutability Most folded rows come back as the input object, so compare identity before scanning fields and blocks. Add a regression test for the invariant the reuse cache depends on: ordering and folding never rewrite producer-owned messages or blocks, which reused rows alias.
This commit is contained in:
+117
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import type * as EditNormalization from '../../../../shared/native-chat-edit-normalize'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages'
|
||||
|
||||
const cost = vi.hoisted(() => ({ edits: 0, milliseconds: 0 }))
|
||||
vi.mock('../../../../shared/native-chat-edit-normalize', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof EditNormalization>()
|
||||
return {
|
||||
...actual,
|
||||
editFilesFromToolPair: (...args: Parameters<typeof actual.editFilesFromToolPair>) => {
|
||||
cost.edits += 1
|
||||
const start = performance.now()
|
||||
const result = actual.editFilesFromToolPair(...args)
|
||||
cost.milliseconds += performance.now() - start
|
||||
return result
|
||||
}
|
||||
}
|
||||
})
|
||||
const { NativeChatMessageList } = await import('./NativeChatMessageList')
|
||||
afterEach(cleanup)
|
||||
|
||||
const EMPTY: never[] = []
|
||||
const loadEarlier = () => {}
|
||||
function Transcript({ items }: { items: AgentJournalRenderItem[] }) {
|
||||
const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY)
|
||||
const session: NativeChatLiveSession = {
|
||||
messages,
|
||||
status: 'working',
|
||||
sessionId: 'session',
|
||||
agent: 'claude',
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
loadEarlier,
|
||||
readPhase: 'ready'
|
||||
}
|
||||
return (
|
||||
<NativeChatMessageList
|
||||
session={session}
|
||||
isWorking
|
||||
expandSignal
|
||||
fontScale={1}
|
||||
showTurnStatus={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function row(index: number, body: AgentJournalRenderItem['body']): AgentJournalRenderItem {
|
||||
return { itemId: `item-${index}`, revision: 1, sequence: index, observedAt: index, body }
|
||||
}
|
||||
|
||||
it('does not re-diff expanded historical edits when an unrelated answer streams', () => {
|
||||
const oldContent = Array.from({ length: 400 }, (_, index) => `old line ${index}`).join('\n')
|
||||
const newContent = oldContent.replace('old line 200', 'changed line 200')
|
||||
const items = Array.from({ length: 20 }, (_, index) =>
|
||||
row(index, {
|
||||
kind: 'tool-call',
|
||||
name: 'Edit',
|
||||
state: 'completed',
|
||||
input: { file_path: `file-${index}.ts`, old_string: oldContent, new_string: newContent }
|
||||
})
|
||||
)
|
||||
items.push(
|
||||
row(20, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Next task' }] })
|
||||
)
|
||||
const tail = row(21, {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'answer' }]
|
||||
})
|
||||
const { rerender } = render(<Transcript items={[...items, tail]} />)
|
||||
expect(cost.edits).toBe(20)
|
||||
cost.edits = 0
|
||||
cost.milliseconds = 0
|
||||
for (let frame = 0; frame < 20; frame += 1) {
|
||||
rerender(
|
||||
<Transcript
|
||||
items={[
|
||||
...items,
|
||||
{
|
||||
...tail,
|
||||
revision: frame + 2,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: `answer ${frame}` }]
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
console.info('Historical edit work over 20 stream frames:', { ...cost })
|
||||
expect(cost.edits).toBe(0)
|
||||
expect(screen.getByText('answer 19')).toBeTruthy()
|
||||
expect(screen.getAllByText('changed line 200')).toHaveLength(20)
|
||||
|
||||
rerender(
|
||||
<Transcript
|
||||
items={[
|
||||
row(0, {
|
||||
kind: 'tool-call',
|
||||
name: 'Edit',
|
||||
state: 'completed',
|
||||
input: { file_path: 'file-0.ts', old_string: oldContent, new_string: 'Revised edit' }
|
||||
}),
|
||||
...items.slice(1),
|
||||
tail
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Revised edit')).toBeTruthy()
|
||||
expect(screen.getAllByText('changed line 200')).toHaveLength(19)
|
||||
})
|
||||
@@ -3,9 +3,7 @@ import { ArrowDown } from 'lucide-react'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { orderNativeChatMessages } from './native-chat-message-grouping'
|
||||
import { stripNoiseMessages } from './native-chat-noise'
|
||||
import { foldToolMessages } from './native-chat-tool-fold'
|
||||
import { createNativeChatMessageListProjection } from './native-chat-message-list-projection'
|
||||
import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll'
|
||||
import { MessageRow } from './NativeChatMessageRow'
|
||||
import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator'
|
||||
@@ -79,10 +77,15 @@ export function NativeChatMessageList({
|
||||
stuckToBottomRef.current = stuckToBottom
|
||||
const { hasMore, loadingEarlier, loadEarlier } = session
|
||||
|
||||
// Keep hidden harness turns as fold boundaries, then strip them before render.
|
||||
const projectMessages = useMemo(
|
||||
() => createNativeChatMessageListProjection(),
|
||||
// Rebound sessions must release the previous transcript's cached rows.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[session.agent, session.sessionId]
|
||||
)
|
||||
const messages = useMemo(
|
||||
() => stripNoiseMessages(foldToolMessages(orderNativeChatMessages(session.messages))),
|
||||
[session.messages]
|
||||
() => projectMessages(session.messages),
|
||||
[projectMessages, session.messages]
|
||||
)
|
||||
const showTypingIndicator = showTurnStatus
|
||||
? isWorking
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { createNativeChatMessageListProjection } from './native-chat-message-list-projection'
|
||||
import { orderNativeChatMessages } from './native-chat-message-grouping'
|
||||
import { stripNoiseMessages } from './native-chat-noise'
|
||||
import { foldToolMessages } from './native-chat-tool-fold'
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
timestamp: number,
|
||||
blocks: NativeChatMessage['blocks'],
|
||||
role: NativeChatMessage['role'] = 'assistant'
|
||||
): NativeChatMessage {
|
||||
return { id, timestamp, blocks, role, source: 'transcript' }
|
||||
}
|
||||
|
||||
it('retains settled folded runs while exposing changed tools, metadata, and attribution boundaries', () => {
|
||||
const project = createNativeChatMessageListProjection()
|
||||
const prose = message('prose', 1, [{ type: 'text', text: 'Inspecting the workspace' }])
|
||||
const call = message('call', 2, [{ type: 'tool-call', name: 'shell', input: { command: 'pwd' } }])
|
||||
const result = message('result', 3, [{ type: 'tool-result', output: '/workspace' }], 'tool')
|
||||
const prompt = message('prompt', 4, [{ type: 'text', text: 'Next task' }], 'user')
|
||||
const tail = message('tail', 5, [{ type: 'text', text: 'Answer' }])
|
||||
const initial = project([prose, call, result, prompt, tail])
|
||||
expect(project([prose, call, result, prompt, tail])).toBe(initial)
|
||||
const streamed = project([
|
||||
prose,
|
||||
call,
|
||||
result,
|
||||
prompt,
|
||||
{ ...tail, blocks: [{ type: 'text', text: 'Answer grows' }] }
|
||||
])
|
||||
expect(streamed[0]).toBe(initial[0])
|
||||
expect(streamed.at(-1)).not.toBe(initial.at(-1))
|
||||
|
||||
const lateResult = { ...result, blocks: [{ type: 'tool-result' as const, output: '/different' }] }
|
||||
const interruption = message(
|
||||
'interrupt',
|
||||
2.5,
|
||||
[{ type: 'text', text: '[Request interrupted by user]' }],
|
||||
'user'
|
||||
)
|
||||
const earlier = message('earlier', 0, [{ type: 'text', text: 'Earlier task' }], 'user')
|
||||
const scenarios = [
|
||||
[prose, call, lateResult, prompt, tail],
|
||||
[prose, call, interruption, result, prompt, tail],
|
||||
[tail, result, prompt, call, prose, earlier],
|
||||
[prose, result, prompt, tail],
|
||||
[prose, call, result],
|
||||
[{ ...prose, source: 'hook' as const, turnId: 'different' }, call, result],
|
||||
[{ ...prose, timestamp: 4 }, call, result, prompt, tail],
|
||||
structuredClone([prose, call, result, prompt, tail]),
|
||||
[]
|
||||
]
|
||||
for (const messages of scenarios) {
|
||||
expect(project(messages)).toEqual(
|
||||
stripNoiseMessages(foldToolMessages(orderNativeChatMessages(messages)))
|
||||
)
|
||||
}
|
||||
expect(project([prose, call, result])[0]).not.toBe(initial[0])
|
||||
})
|
||||
|
||||
// A reused row aliases producer-owned block objects (a journal item's `body.blocks`),
|
||||
// so an in-place rewrite here would freeze what the transcript renders.
|
||||
it('leaves producer-owned messages and blocks untouched', () => {
|
||||
const project = createNativeChatMessageListProjection()
|
||||
const prose = message('prose', 1, [{ type: 'text', text: 'Working' }])
|
||||
const call = message('call', 2, [{ type: 'tool-call', name: 'shell', input: { command: 'pwd' } }])
|
||||
const result = message('result', 3, [{ type: 'tool-result', output: '/workspace' }], 'tool')
|
||||
const later = message(
|
||||
'later',
|
||||
4,
|
||||
[{ type: 'tool-call', name: 'read', input: { path: 'a.ts' } }],
|
||||
'tool'
|
||||
)
|
||||
const input = [prose, call, result, later]
|
||||
const snapshot = structuredClone(input)
|
||||
const folded = project(input)
|
||||
expect(folded[0]?.blocks).toHaveLength(4)
|
||||
expect(folded[0]?.blocks[0]).toBe(prose.blocks[0])
|
||||
project([...input, message('tail', 5, [{ type: 'text', text: 'Answer' }])])
|
||||
expect(input).toEqual(snapshot)
|
||||
expect(prose.blocks).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { orderNativeChatMessages } from './native-chat-message-grouping'
|
||||
import { stripNoiseMessages } from './native-chat-noise'
|
||||
import { foldToolMessages } from './native-chat-tool-fold'
|
||||
|
||||
function sameMessage(left: NativeChatMessage, right: NativeChatMessage): boolean {
|
||||
// Folding only clones the assistant rows that absorb a tool run; every other row
|
||||
// comes back as the input object, so most rows settle without a field scan.
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
const keys = Object.keys(left) as (keyof NativeChatMessage)[]
|
||||
return (
|
||||
keys.length === Object.keys(right).length &&
|
||||
keys.every(
|
||||
(key) => Object.hasOwn(right, key) && (key === 'blocks' || left[key] === right[key])
|
||||
) &&
|
||||
left.blocks.length === right.blocks.length &&
|
||||
left.blocks.every((block, index) => block === right.blocks[index])
|
||||
)
|
||||
}
|
||||
|
||||
export function createNativeChatMessageListProjection(): (
|
||||
messages: NativeChatMessage[]
|
||||
) => NativeChatMessage[] {
|
||||
let previous: NativeChatMessage[] = []
|
||||
let byId = new Map<string, NativeChatMessage>()
|
||||
return (messages) => {
|
||||
const folded = stripNoiseMessages(foldToolMessages(orderNativeChatMessages(messages)))
|
||||
const next = folded.map((message) => {
|
||||
const prior = byId.get(message.id)
|
||||
// Folding clones historical tool runs even when every contributing block is unchanged.
|
||||
return prior && sameMessage(prior, message) ? prior : message
|
||||
})
|
||||
if (
|
||||
next.length === previous.length &&
|
||||
next.every((message, index) => message === previous[index])
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
previous = next
|
||||
byId = new Map(next.map((message) => [message.id, message]))
|
||||
return next
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
|
||||
import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages'
|
||||
|
||||
afterEach(cleanup)
|
||||
const EMPTY: never[] = []
|
||||
function tool(id: string, sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: id,
|
||||
revision: 1,
|
||||
observedAt: sequence,
|
||||
sequence,
|
||||
body: { kind: 'tool-call', name: 'shell', input: { command: 'pwd' }, state: 'running' }
|
||||
}
|
||||
}
|
||||
|
||||
it('retains only unchanged item projections across updates, reorder, deletion, and rehydration', () => {
|
||||
const first = tool('first', 1)
|
||||
const second = tool('second', 2)
|
||||
const { result, rerender } = renderHook(
|
||||
(items: AgentJournalRenderItem[]) => useStructuredAgentSessionMessages(items, EMPTY, EMPTY),
|
||||
{ initialProps: [first, second] }
|
||||
)
|
||||
const initial = result.current
|
||||
rerender([first, second])
|
||||
expect(result.current[0]).toBe(initial[0])
|
||||
expect(result.current[1]).toBe(initial[1])
|
||||
const completed: AgentJournalRenderItem = {
|
||||
...second,
|
||||
revision: 2,
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pwd' },
|
||||
state: 'completed',
|
||||
output: { head: '/workspace', truncated: false, byteLength: 10, digest: 'a' }
|
||||
}
|
||||
}
|
||||
for (const items of [
|
||||
[first, completed],
|
||||
[completed, first],
|
||||
[completed],
|
||||
[structuredClone(completed)]
|
||||
]) {
|
||||
rerender(items)
|
||||
expect(result.current).toEqual(projectStructuredAgentSessionMessages(items, EMPTY, EMPTY))
|
||||
expect(result.current.find((message) => message.id === 'second')).not.toBe(initial[1])
|
||||
}
|
||||
const replacement = {
|
||||
...first,
|
||||
body: {
|
||||
kind: 'message' as const,
|
||||
role: 'user' as const,
|
||||
blocks: [{ type: 'text' as const, text: 'Another session with the same item id' }]
|
||||
}
|
||||
}
|
||||
rerender([replacement])
|
||||
expect(result.current).toEqual(projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY))
|
||||
expect(result.current[0]).not.toBe(initial[0])
|
||||
})
|
||||
|
||||
it('keeps optimistic sends and their settlement identical to uncached projection', () => {
|
||||
const entry = createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'send',
|
||||
sessionId: 'session',
|
||||
text: 'Send this',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
})
|
||||
const submission: AgentJournalSubmission = {
|
||||
clientMessageId: 'send',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'pending',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: null
|
||||
}
|
||||
const { result, rerender } = renderHook(
|
||||
({
|
||||
items,
|
||||
submissions
|
||||
}: {
|
||||
items: AgentJournalRenderItem[]
|
||||
submissions: AgentJournalSubmission[]
|
||||
}) => useStructuredAgentSessionMessages(items, [entry], submissions),
|
||||
{ initialProps: { items: [tool('tool', 1)], submissions: [submission] } }
|
||||
)
|
||||
for (const dispatchState of ['pending', 'unknown', 'accepted'] as const) {
|
||||
const props = { items: [tool('tool', 1)], submissions: [{ ...submission, dispatchState }] }
|
||||
rerender(props)
|
||||
expect(result.current).toEqual(
|
||||
projectStructuredAgentSessionMessages(props.items, [entry], props.submissions)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('does no transcript projection work on a status-only render', () => {
|
||||
const items = [tool('tool', 1)]
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useStructuredAgentSessionMessages(items, EMPTY, EMPTY)
|
||||
)
|
||||
const initial = result.current
|
||||
rerender()
|
||||
expect(result.current).toBe(initial)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection'
|
||||
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
|
||||
|
||||
export function useStructuredAgentSessionMessages(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
) {
|
||||
const projectItems = useMemo(() => {
|
||||
// Journal revisions replace item objects; weak keys release removed history.
|
||||
const byItem = new WeakMap<AgentJournalRenderItem, NativeChatMessage | null>()
|
||||
return (rows: readonly AgentJournalRenderItem[]): NativeChatMessage[] => {
|
||||
const messages: NativeChatMessage[] = []
|
||||
for (const row of rows) {
|
||||
if (!byItem.has(row)) {
|
||||
byItem.set(row, projectStructuredItemToNativeChat(row))
|
||||
}
|
||||
const message = byItem.get(row)
|
||||
if (message) {
|
||||
messages.push(message)
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}, [])
|
||||
return useMemo(
|
||||
() => projectStructuredAgentSessionMessages(items, outbox, submissions, projectItems),
|
||||
[items, outbox, submissions, projectItems]
|
||||
)
|
||||
}
|
||||
@@ -33,10 +33,10 @@ import {
|
||||
import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold'
|
||||
import { useStructuredAgentSessionRead } from './use-structured-agent-session-read'
|
||||
import {
|
||||
projectStructuredAgentSessionMessages,
|
||||
pendingStructuredSessionPrompts,
|
||||
type StructuredPromptItem
|
||||
} from './structured-agent-session-message-projection'
|
||||
import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages'
|
||||
import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity'
|
||||
import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write'
|
||||
|
||||
@@ -250,6 +250,8 @@ export function useStructuredAgentSession(args: {
|
||||
)
|
||||
|
||||
const prompts = pendingStructuredSessionPrompts(state.items)
|
||||
const { outbox } = outboxController
|
||||
const messages = useStructuredAgentSessionMessages(state.items, outbox, state.submissions)
|
||||
return {
|
||||
conversationCommands:
|
||||
conversationSupport?.sessionId === sessionId ? conversationSupport.commands : [],
|
||||
@@ -257,9 +259,7 @@ export function useStructuredAgentSession(args: {
|
||||
conversationCommands.sendStructuredConversationCommand({
|
||||
command,
|
||||
pending: commandPending,
|
||||
blocked: Boolean(
|
||||
turnId || prompts.length || isMonitoringBackgroundTasks || outboxController.outbox.length
|
||||
),
|
||||
blocked: Boolean(turnId || prompts.length || isMonitoringBackgroundTasks || outbox.length),
|
||||
send: (command) =>
|
||||
mutate<AgentSessionConversationCommandResult>(
|
||||
'agentSession.conversationCommand',
|
||||
@@ -267,18 +267,14 @@ export function useStructuredAgentSession(args: {
|
||||
{ command }
|
||||
)
|
||||
}),
|
||||
messages: projectStructuredAgentSessionMessages(
|
||||
state.items,
|
||||
outboxController.outbox,
|
||||
state.submissions
|
||||
),
|
||||
messages,
|
||||
status: state.status,
|
||||
error: state.error ?? writeError ?? outboxController.error,
|
||||
hasOlder: state.hasOlder,
|
||||
loadingOlder,
|
||||
loadOlder,
|
||||
prompts,
|
||||
outbox: outboxController.outbox,
|
||||
outbox,
|
||||
blockedClientMessageId: outboxController.blockedClientMessageId,
|
||||
send: (...input: Parameters<typeof outboxController.send>) =>
|
||||
!commandPending.current && outboxController.send(...input),
|
||||
|
||||
@@ -10,12 +10,13 @@ import { projectStructuredItemsToNativeChat } from './structured-agent-session-p
|
||||
export function projectStructuredAgentSessionMessages(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
submissions: readonly AgentJournalSubmission[],
|
||||
projectItems = projectStructuredItemsToNativeChat
|
||||
): NativeChatMessage[] {
|
||||
const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions)
|
||||
const journalled = new Set(items.map((item) => item.itemId))
|
||||
return [
|
||||
...projectStructuredItemsToNativeChat(items),
|
||||
...projectItems(items),
|
||||
...optimistic
|
||||
.filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId)))
|
||||
.map((entry): NativeChatMessage => ({
|
||||
|
||||
Reference in New Issue
Block a user