feat(mothership): request ids (#3645)

* Include rid

* Persist rid

* fix ui

* address comments

* update types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
Siddharth Ganesan
2026-03-17 18:24:27 -07:00
committed by GitHub
co-authored by Vikhyath Mondreti
parent 5f89c7140c
commit 168cd585cb
17 changed files with 151 additions and 5 deletions
@@ -279,6 +279,7 @@ export async function POST(req: NextRequest) {
role: 'assistant' as const,
content: result.content,
timestamp: new Date().toISOString(),
...(result.requestId ? { requestId: result.requestId } : {}),
}
if (result.toolCalls.length > 0) {
assistantMessage.toolCalls = result.toolCalls
@@ -1,5 +1,6 @@
export { ErrorState, type ErrorStateProps } from './error'
export { InlineRenameInput } from './inline-rename-input'
export { MessageActions } from './message-actions'
export { ownerCell } from './resource/components/owner-cell/owner-cell'
export type {
BreadcrumbEditing,
@@ -0,0 +1 @@
export { MessageActions } from './message-actions'
@@ -0,0 +1,84 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
interface MessageActionsProps {
content: string
requestId?: string
}
export function MessageActions({ content, requestId }: MessageActionsProps) {
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
const resetTimeoutRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
}
}, [])
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
try {
await navigator.clipboard.writeText(text)
setCopied(type)
if (resetTimeoutRef.current !== null) {
window.clearTimeout(resetTimeoutRef.current)
}
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
} catch {
return
}
}, [])
if (!content && !requestId) {
return null
}
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='More options'
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
onClick={(event) => event.stopPropagation()}
>
<Ellipsis className='h-3 w-3' strokeWidth={2} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' side='top' sideOffset={4}>
<DropdownMenuItem
disabled={!content}
onSelect={(event) => {
event.stopPropagation()
void copyToClipboard(content, 'message')
}}
>
{copied === 'message' ? <Check /> : <Copy />}
<span>Copy Message</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!requestId}
onSelect={(event) => {
event.stopPropagation()
if (requestId) {
void copyToClipboard(requestId, 'request')
}
}}
>
{copied === 'request' ? <Check /> : <Hash />}
<span>Copy Request ID</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -13,6 +13,7 @@ import {
LandingWorkflowSeedStorage,
} from '@/lib/core/utils/browser-storage'
import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { useChatHistory, useMarkTaskRead } from '@/hooks/queries/tasks'
import type { ChatContext } from '@/stores/panel'
import { useSidebarStore } from '@/stores/sidebar/store'
@@ -414,7 +415,12 @@ export function Home({ chatId }: HomeProps = {}) {
const isLastMessage = index === messages.length - 1
return (
<div key={msg.id} className='pb-4'>
<div key={msg.id} className='group/msg relative pb-5'>
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={msg.content} requestId={msg.requestId} />
</div>
)}
<MessageContent
blocks={msg.contentBlocks || []}
fallbackContent={msg.content}
@@ -142,6 +142,7 @@ function mapStoredMessage(msg: TaskStoredMessage): ChatMessage {
id: msg.id,
role: msg.role,
content: msg.content,
...(msg.requestId ? { requestId: msg.requestId } : {}),
}
const hasContentBlocks = Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0
@@ -509,6 +510,7 @@ export function useChat(
let activeSubagent: string | undefined
let runningText = ''
let lastContentSource: 'main' | 'subagent' | null = null
let streamRequestId: string | undefined
streamingContentRef.current = ''
streamingBlocksRef.current = []
@@ -526,14 +528,21 @@ export function useChat(
const flush = () => {
if (isStale()) return
streamingBlocksRef.current = [...blocks]
const snapshot = { content: runningText, contentBlocks: [...blocks] }
const snapshot: Partial<ChatMessage> = {
content: runningText,
contentBlocks: [...blocks],
}
if (streamRequestId) snapshot.requestId = streamRequestId
setMessages((prev) => {
if (expectedGen !== undefined && streamGenRef.current !== expectedGen) return prev
const idx = prev.findIndex((m) => m.id === assistantId)
if (idx >= 0) {
return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m))
}
return [...prev, { id: assistantId, role: 'assistant' as const, ...snapshot }]
return [
...prev,
{ id: assistantId, role: 'assistant' as const, content: '', ...snapshot },
]
})
}
@@ -597,6 +606,14 @@ export function useChat(
}
break
}
case 'request_id': {
const rid = typeof parsed.data === 'string' ? parsed.data : undefined
if (rid) {
streamRequestId = rid
flush()
}
break
}
case 'content': {
const chunk = typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '')
if (chunk) {
@@ -33,6 +33,7 @@ export interface QueuedMessage {
*/
export type SSEEventType =
| 'chat_id'
| 'request_id'
| 'title_updated'
| 'content'
| 'reasoning' // openai reasoning - render as thinking text
@@ -199,6 +200,7 @@ export interface ChatMessage {
contentBlocks?: ContentBlock[]
attachments?: ChatMessageAttachment[]
contexts?: ChatMessageContext[]
requestId?: string
}
export const SUBAGENT_LABELS: Record<SubagentName, string> = {
@@ -3,6 +3,7 @@
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import {
OptionsSelector,
parseSpecialTags,
@@ -409,10 +410,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (isAssistant) {
return (
<div
className={`w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
className={`group/msg relative w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
>
<div className='max-w-full space-y-[4px] px-[2px] pb-[4px]'>
{!isStreaming && (message.content || message.contentBlocks?.length) && (
<div className='absolute right-0 bottom-0 z-10'>
<MessageActions content={message.content} requestId={message.requestId} />
</div>
)}
<div className='max-w-full space-y-[4px] px-[2px] pb-5'>
{/* Content blocks in chronological order */}
{memoizedContentBlocks || (isStreaming && <div className='min-h-0' />)}
+1
View File
@@ -54,6 +54,7 @@ export interface TaskStoredMessage {
id: string
role: 'user' | 'assistant'
content: string
requestId?: string
toolCalls?: TaskStoredToolCall[]
contentBlocks?: TaskStoredContentBlock[]
fileAttachments?: TaskStoredFileAttachment[]
@@ -92,6 +92,7 @@ export function flushStreamingUpdates(set: StoreSet) {
if (update) {
return {
...msg,
requestId: update.requestId ?? msg.requestId,
content: '',
contentBlocks:
update.contentBlocks.length > 0
@@ -129,6 +130,7 @@ export function updateStreamingMessage(set: StoreSet, context: ClientStreamingCo
const newMessages = [...messages]
newMessages[messages.length - 1] = {
...lastMessage,
requestId: lastMessageUpdate.requestId ?? lastMessage.requestId,
content: '',
contentBlocks:
lastMessageUpdate.contentBlocks.length > 0
@@ -143,6 +145,7 @@ export function updateStreamingMessage(set: StoreSet, context: ClientStreamingCo
if (update) {
return {
...msg,
requestId: update.requestId ?? msg.requestId,
content: '',
contentBlocks:
update.contentBlocks.length > 0
@@ -429,6 +432,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
writeActiveStreamToStorage(updatedStream)
}
},
request_id: (data, context) => {
const requestId = typeof data.data === 'string' ? data.data : undefined
if (requestId) {
context.requestId = requestId
}
},
title_updated: (_data, _context, get, set) => {
const title = _data.title
if (!title) return
+1
View File
@@ -22,6 +22,7 @@ export interface ClientContentBlock {
export interface StreamingContext {
messageId: string
requestId?: string
accumulatedContent: string
contentBlocks: ClientContentBlock[]
currentTextBlock: ClientContentBlock | null
@@ -141,6 +141,10 @@ export function serializeMessagesForDB(
timestamp,
}
if (msg.requestId) {
serialized.requestId = msg.requestId
}
if (Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0) {
serialized.contentBlocks = deepClone(msg.contentBlocks)
}
@@ -76,6 +76,7 @@ export async function orchestrateCopilotStream(
contentBlocks: context.contentBlocks,
toolCalls: buildToolCallSummaries(context),
chatId: context.chatId,
requestId: context.requestId,
errors: context.errors.length ? context.errors : undefined,
usage: context.usage,
cost: context.cost,
@@ -187,6 +187,12 @@ export const sseHandlers: Record<string, SSEHandler> = {
execContext.chatId = chatId
}
},
request_id: (event, context) => {
const rid = typeof event.data === 'string' ? event.data : undefined
if (rid) {
context.requestId = rid
}
},
title_updated: () => {},
tool_result: (event, context) => {
const data = getEventData(event)
@@ -2,6 +2,7 @@ import type { MothershipResource } from '@/lib/copilot/resource-types'
export type SSEEventType =
| 'chat_id'
| 'request_id'
| 'title_updated'
| 'content'
| 'reasoning'
@@ -88,6 +89,7 @@ export interface ContentBlock {
export interface StreamingContext {
chatId?: string
requestId?: string
messageId: string
accumulatedContent: string
contentBlocks: ContentBlock[]
@@ -154,6 +156,7 @@ export interface OrchestratorResult {
contentBlocks: ContentBlock[]
toolCalls: ToolCallSummary[]
chatId?: string
requestId?: string
error?: string
errors?: string[]
usage?: { prompt: number; completion: number }
+2
View File
@@ -224,6 +224,7 @@ function replaceTextBlocks(blocks: ClientContentBlock[], text: string): ClientCo
function createClientStreamingContext(messageId: string): ClientStreamingContext {
return {
messageId,
requestId: undefined,
accumulatedContent: '',
contentBlocks: [],
currentTextBlock: null,
@@ -2043,6 +2044,7 @@ export const useCopilotStore = create<CopilotStore>()(
msg.id === assistantMessageId
? {
...msg,
requestId: context.requestId ?? msg.requestId,
content: finalContentWithOptions,
contentBlocks: sanitizedContentBlocks,
}
+1
View File
@@ -70,6 +70,7 @@ export interface CopilotMessage {
role: 'user' | 'assistant' | 'system'
content: string
timestamp: string
requestId?: string
citations?: { id: number; title: string; url: string; similarity?: number }[]
toolCalls?: CopilotToolCall[]
contentBlocks?: ClientContentBlock[]