mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): sync share metadata and active-turn availability (#1306)
* fix(web): align sharing with session state * fix(web): keep share state in sync * fix(web): fail closed for trimmed active turns * fix(web): refresh prepared share images * fix(web): preserve sharing during queued thinking * fix: track a stable active turn boundary * fix: anchor active turns to persisted messages * fix(web): include Pi reasoning in share metadata * fix(hub): refresh queued thinking grace on retry * test(web): isolate mobile thread scroll setup * fix(hub): advance queued turn boundaries * fix(hub): guard queued boundary advancement * perf(web): precompute running turn sharing * fix(hub): use hub time for turn boundaries * perf(web): pause closed share metadata timer
This commit is contained in:
@@ -15,6 +15,7 @@ import type { AgentEvent, ToolCallBlock } from '@/chat/types'
|
||||
import type { ToolGroupBlock, VisibleChatBlock } from '@/chat/toolGroups'
|
||||
import { visibleBlockRole } from '@/chat/toolGroups'
|
||||
import type { AttachmentMetadata, MessageStatus as HappyMessageStatus, Session } from '@/types/api'
|
||||
import { buildShareHiddenByMessageId } from '@/lib/shareTurnAvailability'
|
||||
|
||||
/**
|
||||
* Aggregated metadata for a multi-turn response group, surfaced on the
|
||||
@@ -54,6 +55,8 @@ export type HappyChatMessageMetadata = {
|
||||
export type HappyRuntimeExtras = Readonly<{
|
||||
messagesVersion: number
|
||||
historyVersion: number
|
||||
runningSince: number
|
||||
shareHiddenByMessageId: ReadonlySet<string>
|
||||
}>
|
||||
|
||||
function formatCodexReviewText(review: CodexReview): string {
|
||||
@@ -714,10 +717,17 @@ export function useHappyRuntime(props: {
|
||||
await props.onAbort()
|
||||
}, [props.onAbort])
|
||||
|
||||
const runningSince = props.session.activeTurnStartedAt ?? 0
|
||||
const shareHiddenByMessageId = useMemo(
|
||||
() => buildShareHiddenByMessageId(convertedMessages, isRunning, runningSince),
|
||||
[convertedMessages, isRunning, runningSince]
|
||||
)
|
||||
const extras = useMemo<HappyRuntimeExtras>(() => ({
|
||||
messagesVersion: props.messagesVersion,
|
||||
historyVersion: props.historyVersion
|
||||
}), [props.messagesVersion, props.historyVersion])
|
||||
historyVersion: props.historyVersion,
|
||||
runningSince,
|
||||
shareHiddenByMessageId
|
||||
}), [props.messagesVersion, props.historyVersion, runningSince, shareHiddenByMessageId])
|
||||
|
||||
// Memoize the adapter to avoid recreating on every render
|
||||
// useExternalStoreRuntime may use adapter identity for subscriptions
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildShareHiddenByMessageId, shouldHideShareForRunningTurn } from './shareTurnAvailability'
|
||||
|
||||
const messages = [
|
||||
{ id: 'user-old', role: 'user' },
|
||||
{ id: 'assistant-old', role: 'assistant' },
|
||||
{ id: 'user-active', role: 'user' },
|
||||
{ id: 'assistant-active', role: 'assistant' },
|
||||
]
|
||||
|
||||
describe('shouldHideShareForRunningTurn', () => {
|
||||
it('builds one lookup containing only the active running turn', () => {
|
||||
expect([...buildShareHiddenByMessageId(messages, true)]).toEqual(['user-active', 'assistant-active'])
|
||||
})
|
||||
|
||||
it('keeps historical turns shareable while the latest turn is running', () => {
|
||||
expect(shouldHideShareForRunningTurn(messages, 'user-old', true)).toBe(false)
|
||||
expect(shouldHideShareForRunningTurn(messages, 'assistant-old', true)).toBe(false)
|
||||
})
|
||||
|
||||
it('hides both sides of the active turn while it is running', () => {
|
||||
expect(shouldHideShareForRunningTurn(messages, 'user-active', true)).toBe(true)
|
||||
expect(shouldHideShareForRunningTurn(messages, 'assistant-active', true)).toBe(true)
|
||||
})
|
||||
|
||||
it('restores the active turn after generation finishes', () => {
|
||||
expect(shouldHideShareForRunningTurn(messages, 'user-active', false)).toBe(false)
|
||||
expect(shouldHideShareForRunningTurn(messages, 'assistant-active', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let a failed queued attachment redefine the running turn', () => {
|
||||
const messagesWithFailedAttachment = [
|
||||
...messages,
|
||||
{
|
||||
id: 'user-failed',
|
||||
role: 'user',
|
||||
metadata: { custom: { status: 'failed', invokedAt: null } },
|
||||
},
|
||||
]
|
||||
|
||||
expect(shouldHideShareForRunningTurn(messagesWithFailedAttachment, 'user-active', true)).toBe(true)
|
||||
expect(shouldHideShareForRunningTurn(messagesWithFailedAttachment, 'assistant-active', true)).toBe(true)
|
||||
expect(shouldHideShareForRunningTurn(messagesWithFailedAttachment, 'user-failed', true)).toBe(true)
|
||||
})
|
||||
|
||||
it('fails open when the current message is not in the thread snapshot', () => {
|
||||
expect(shouldHideShareForRunningTurn(messages, 'missing', true)).toBe(false)
|
||||
})
|
||||
|
||||
it('hides every visible assistant message when the active user boundary was trimmed', () => {
|
||||
const assistantOnlyMessages = [
|
||||
{ id: 'assistant-active-1', role: 'assistant' },
|
||||
{ id: 'assistant-active-2', role: 'assistant' },
|
||||
]
|
||||
|
||||
expect(shouldHideShareForRunningTurn(assistantOnlyMessages, 'assistant-active-1', true)).toBe(true)
|
||||
expect(shouldHideShareForRunningTurn(assistantOnlyMessages, 'assistant-active-2', true)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps completed turns shareable before the queued prompt is consumed', () => {
|
||||
const runningSince = Date.UTC(2026, 7, 2, 10, 0, 0)
|
||||
const completedMessages = [
|
||||
{ id: 'user-completed', role: 'user', createdAt: new Date(runningSince - 2_000) },
|
||||
{ id: 'assistant-completed', role: 'assistant', createdAt: new Date(runningSince - 1_000) },
|
||||
]
|
||||
|
||||
expect(shouldHideShareForRunningTurn(completedMessages, 'user-completed', true, runningSince)).toBe(false)
|
||||
expect(shouldHideShareForRunningTurn(completedMessages, 'assistant-completed', true, runningSince)).toBe(false)
|
||||
})
|
||||
|
||||
it('restores a completed turn when queued grace advances beyond its invocation timestamps', () => {
|
||||
const completedAt = Date.UTC(2026, 7, 2, 10, 0, 0)
|
||||
const completedTurn = [
|
||||
{ id: 'user-a', role: 'user', createdAt: new Date(completedAt - 500) },
|
||||
{ id: 'assistant-a', role: 'assistant', createdAt: new Date(completedAt - 100) },
|
||||
]
|
||||
|
||||
expect(shouldHideShareForRunningTurn(completedTurn, 'user-a', true, completedAt)).toBe(false)
|
||||
expect(shouldHideShareForRunningTurn(completedTurn, 'assistant-a', true, completedAt)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the accepted turn hidden after later keepalives', () => {
|
||||
const runningSince = Date.UTC(2026, 7, 2, 10, 0, 0)
|
||||
const messagesAfterConsumption = [
|
||||
{ id: 'user-active', role: 'user', createdAt: new Date(runningSince) },
|
||||
{ id: 'assistant-partial', role: 'assistant', createdAt: new Date(runningSince + 5_000) },
|
||||
]
|
||||
|
||||
expect(shouldHideShareForRunningTurn(messagesAfterConsumption, 'user-active', true, runningSince)).toBe(true)
|
||||
expect(shouldHideShareForRunningTurn(messagesAfterConsumption, 'assistant-partial', true, runningSince)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
type ShareMessage = {
|
||||
id: string
|
||||
role: string
|
||||
createdAt?: Date
|
||||
metadata?: {
|
||||
custom?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function isShareTurnUserMessage(message: ShareMessage): boolean {
|
||||
if (message.role !== 'user') return false
|
||||
|
||||
const custom = message.metadata?.custom as {
|
||||
status?: string
|
||||
invokedAt?: number | null
|
||||
} | undefined
|
||||
|
||||
return custom?.status !== 'failed' && custom?.invokedAt !== null
|
||||
}
|
||||
|
||||
export function shouldHideShareForRunningTurn(
|
||||
messages: readonly ShareMessage[],
|
||||
currentMessageId: string,
|
||||
threadIsRunning: boolean,
|
||||
runningSince = 0
|
||||
): boolean {
|
||||
return buildShareHiddenByMessageId(messages, threadIsRunning, runningSince).has(currentMessageId)
|
||||
}
|
||||
|
||||
export function buildShareHiddenByMessageId(
|
||||
messages: readonly ShareMessage[],
|
||||
threadIsRunning: boolean,
|
||||
runningSince = 0
|
||||
): ReadonlySet<string> {
|
||||
if (!threadIsRunning) return new Set()
|
||||
|
||||
const activeUserIndex = messages.findLastIndex(isShareTurnUserMessage)
|
||||
const hidden = new Set<string>()
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index]
|
||||
if (!message) continue
|
||||
const createdAt = message.createdAt?.getTime() ?? 0
|
||||
if (runningSince > 0 && createdAt > 0 && createdAt < runningSince) continue
|
||||
if (activeUserIndex < 0 || index >= activeUserIndex) hidden.add(message.id)
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_SESSION_HEADER_METADATA } from '@/hooks/useSessionHeaderMetadata'
|
||||
import { getShareTurnReasoningLabel, selectShareTurnMetadata } from './shareTurnMetadata'
|
||||
|
||||
describe('selectShareTurnMetadata', () => {
|
||||
const available = {
|
||||
agent: { text: 'codex', flavor: 'codex' },
|
||||
machine: { text: 'Machine: workstation' },
|
||||
lastActive: { text: '2 minutes ago' },
|
||||
model: { text: 'Model: gpt-5.6-sol' },
|
||||
reasoning: { text: 'Reasoning: high' },
|
||||
fastMode: { text: 'fast' },
|
||||
createdAt: { text: 'Created: Aug 2, 2026, 10:00' },
|
||||
updatedAt: { text: 'Updated: Aug 2, 2026, 10:30' },
|
||||
worktree: { text: 'Worktree: feat/example' },
|
||||
}
|
||||
|
||||
it('uses the desktop session-header order and default visibility', () => {
|
||||
expect(selectShareTurnMetadata(DEFAULT_SESSION_HEADER_METADATA, available).map((item) => item.key)).toEqual([
|
||||
'agent', 'machine', 'lastActive', 'model', 'reasoning', 'fastMode', 'worktree',
|
||||
])
|
||||
})
|
||||
|
||||
it('honors configured visibility and omits unavailable values', () => {
|
||||
const preferences = Object.fromEntries(
|
||||
Object.keys(DEFAULT_SESSION_HEADER_METADATA).map((key) => [key, false])
|
||||
) as typeof DEFAULT_SESSION_HEADER_METADATA
|
||||
preferences.showLabels = true
|
||||
preferences.createdAt = true
|
||||
preferences.updatedAt = true
|
||||
preferences.machine = true
|
||||
|
||||
expect(selectShareTurnMetadata(preferences, {
|
||||
...available,
|
||||
machine: undefined,
|
||||
}).map((item) => item.key)).toEqual(['createdAt', 'updatedAt'])
|
||||
})
|
||||
|
||||
it('uses Pi effort for shared reasoning metadata', () => {
|
||||
expect(getShareTurnReasoningLabel('pi', null, 'max', true)).toBe('reasoning max')
|
||||
expect(getShareTurnReasoningLabel('pi', null, 'max', false)).toBe('max')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SessionHeaderMetadataPreferences } from '@/hooks/useSessionHeaderMetadata'
|
||||
import { formatReasoningLabel, getReasoningEffortForFlavor } from '@/lib/codexStatusLabels'
|
||||
|
||||
export type ShareTurnMetadataKey = Exclude<keyof SessionHeaderMetadataPreferences, 'showLabels'>
|
||||
|
||||
export type ShareTurnMetadataItem = {
|
||||
key: ShareTurnMetadataKey
|
||||
text: string
|
||||
flavor?: string | null
|
||||
}
|
||||
|
||||
export function getShareTurnReasoningLabel(
|
||||
agentFlavor: string | null | undefined,
|
||||
modelReasoningEffort: string | null | undefined,
|
||||
effort: string | null | undefined,
|
||||
showLabels: boolean
|
||||
): string | null {
|
||||
const reasoningEffort = getReasoningEffortForFlavor(agentFlavor, modelReasoningEffort, effort)
|
||||
return reasoningEffort ? formatReasoningLabel(reasoningEffort, showLabels) : null
|
||||
}
|
||||
|
||||
const SESSION_HEADER_METADATA_ORDER: ReadonlyArray<ShareTurnMetadataKey> = [
|
||||
'agent',
|
||||
'machine',
|
||||
'lastActive',
|
||||
'model',
|
||||
'reasoning',
|
||||
'fastMode',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'worktree',
|
||||
]
|
||||
|
||||
export function selectShareTurnMetadata(
|
||||
preferences: SessionHeaderMetadataPreferences,
|
||||
available: Partial<Record<ShareTurnMetadataKey, Omit<ShareTurnMetadataItem, 'key'>>>
|
||||
): ShareTurnMetadataItem[] {
|
||||
return SESSION_HEADER_METADATA_ORDER.flatMap((key) => {
|
||||
const item = available[key]
|
||||
return preferences[key] && item?.text ? [{ key, ...item }] : []
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user