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:
Ananovo
2026-08-04 11:18:08 +08:00
committed by GitHub
parent c3bed919b8
commit a0c676818f
20 changed files with 565 additions and 57 deletions
+12 -2
View File
@@ -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
+92
View File
@@ -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)
})
})
+47
View File
@@ -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
}
+43
View File
@@ -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')
})
})
+42
View File
@@ -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 }] : []
})
}