From a0c676818f4ee2fe8b86385b905e4749d7cda61e Mon Sep 17 00:00:00 2001 From: Ananovo Date: Tue, 4 Aug 2026 11:18:08 +0800 Subject: [PATCH] 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 --- e2e/share-turn.spec.ts | 28 ++++- hub/src/sync/aliveEvents.test.ts | 114 +++++++++++++++++- hub/src/sync/messageService.ts | 4 +- hub/src/sync/sessionCache.ts | 23 +++- hub/src/sync/syncEngine.ts | 4 +- shared/src/schemas.ts | 2 + web/e2e-fixtures/share-turn-fixture.tsx | 20 ++- .../HappyThread.mobile-scroll.test.tsx | 4 + .../components/AssistantChat/HappyThread.tsx | 71 +++++++++-- .../AssistantChat/ShareTurnDialog.tsx | 35 +++--- .../AssistantChat/messages/MessageActions.tsx | 12 +- web/src/components/SessionChat.tsx | 1 + web/src/components/SessionHeader.tsx | 9 +- web/src/hooks/useMinuteTick.test.ts | 41 +++++++ web/src/hooks/useMinuteTick.ts | 16 +++ web/src/lib/assistant-runtime.ts | 14 ++- web/src/lib/shareTurnAvailability.test.ts | 92 ++++++++++++++ web/src/lib/shareTurnAvailability.ts | 47 ++++++++ web/src/lib/shareTurnMetadata.test.ts | 43 +++++++ web/src/lib/shareTurnMetadata.ts | 42 +++++++ 20 files changed, 565 insertions(+), 57 deletions(-) create mode 100644 web/src/hooks/useMinuteTick.test.ts create mode 100644 web/src/hooks/useMinuteTick.ts create mode 100644 web/src/lib/shareTurnAvailability.test.ts create mode 100644 web/src/lib/shareTurnAvailability.ts create mode 100644 web/src/lib/shareTurnMetadata.test.ts create mode 100644 web/src/lib/shareTurnMetadata.ts diff --git a/e2e/share-turn.spec.ts b/e2e/share-turn.spec.ts index abe4acf5..7d000aa6 100644 --- a/e2e/share-turn.spec.ts +++ b/e2e/share-turn.spec.ts @@ -86,7 +86,7 @@ test('exports a text-only user fallback alongside assistant DOM', async ({ page await page.getByRole('button', { name: 'Open share preview' }).click() const dialog = page.getByRole('dialog') - await expect(dialog.getByText(/请导出这一轮复杂对话/)).toBeVisible() + await expect(dialog.getByText(/这个失败不用说吧/)).toBeVisible() await expect(dialog.getByText('Complex response fixture')).toBeVisible() await dialog.screenshot({ path: testInfo.outputPath('fallback-preview.png') }) @@ -139,6 +139,32 @@ test('localizes the share dialog actions in Chinese', async ({ page }) => { await expect(dialog.getByRole('button', { name: '下载' })).toBeVisible() }) +test('matches configured session-header metadata in the share preview', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('hapi-session-header-metadata', JSON.stringify({ + showLabels: false, + agent: false, + model: false, + reasoning: false, + fastMode: false, + machine: false, + lastActive: false, + createdAt: true, + updatedAt: true, + worktree: false, + }))) + await page.goto('/e2e-fixtures/share-turn-fixture.html') + await page.getByRole('button', { name: 'Open share preview' }).click() + + const dialog = page.getByRole('dialog') + await expect(dialog.getByText('Aug 2, 2026, 10:00 AM', { exact: true })).toBeVisible() + await expect(dialog.getByText('Aug 2, 2026, 10:30 AM', { exact: true })).toBeVisible() + await expect(dialog.getByText('Created:', { exact: false })).toHaveCount(0) + await expect(dialog.getByText('codex', { exact: true })).toHaveCount(0) + await expect(dialog.getByText('fixture-host', { exact: false })).toHaveCount(0) + await expect(dialog.getByText('gpt-5.6-sol', { exact: true })).toHaveCount(0) + await expect(dialog.getByText('feat/share-turn-polish', { exact: false })).toHaveCount(0) +}) + test('keeps code and image controls interactive in preview', async ({ page }, testInfo) => { await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) await page.goto('/e2e-fixtures/share-turn-fixture.html') diff --git a/hub/src/sync/aliveEvents.test.ts b/hub/src/sync/aliveEvents.test.ts index 8cdaaae8..aaad878a 100644 --- a/hub/src/sync/aliveEvents.test.ts +++ b/hub/src/sync/aliveEvents.test.ts @@ -188,6 +188,13 @@ describe('alive incremental events', () => { expect(engine.getSession(session.id)?.activeAt).toBe(activeAtBeforeSend) expect(emittedSocketUpdates.length).toBeGreaterThan(0) + const received = events.find((event) => event.type === 'message-received') + expect(received).toBeDefined() + if (!received || received.type !== 'message-received') { + return + } + expect(engine.getSession(session.id)?.activeTurnStartedAt).toBe(received.message.createdAt) + const update = events.find((event) => { return event.type === 'session-updated' && typeof event.data === 'object' @@ -200,6 +207,9 @@ describe('alive incremental events', () => { } expect(update.data).toEqual(expect.objectContaining({ thinking: true })) + expect(update.data).toEqual(expect.objectContaining({ + activeTurnStartedAt: expect.any(Number) + })) expect(update.data).not.toHaveProperty('activeAt') expect((update.data as { updatedAt?: unknown }).updatedAt).toEqual(expect.any(Number)) } finally { @@ -237,7 +247,91 @@ describe('alive incremental events', () => { expect(events.find((event) => event.type === 'session-updated')).toBeUndefined() }) - it('keeps queued thinking true across false heartbeats during the grace window', () => { + it('starts a fresh grace window when an old local message is retried', async () => { + const store = new Store(':memory:') + const io = { + of: () => ({ + to: () => ({ emit() {} }) + }) + } + const engine = new SyncEngine( + store, + io as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + const now = Date.now() + const originalNow = Date.now + + try { + const session = engine.getOrCreateSession( + 'session-retry-thinking-grace', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + + Date.now = () => now - 20_000 + engine.handleSessionAlive({ sid: session.id, time: now - 20_000, thinking: false }) + await engine.sendMessage(session.id, { text: 'retry me', localId: 'stable-local-id' }) + const storedTurnStartedAt = engine.getSession(session.id)?.activeTurnStartedAt + + Date.now = () => now + engine.handleSessionAlive({ sid: session.id, time: now, thinking: false }) + expect(engine.getSession(session.id)?.thinking).toBe(false) + + await engine.sendMessage(session.id, { text: 'retry me', localId: 'stable-local-id' }) + expect(engine.getSession(session.id)?.activeTurnStartedAt).toBe(storedTurnStartedAt) + + Date.now = () => now + 1_000 + engine.handleSessionAlive({ sid: session.id, time: now + 1_000, thinking: false }) + expect(engine.getSession(session.id)?.thinking).toBe(true) + } finally { + Date.now = originalNow + engine.stop() + } + }) + + it('keeps the active boundary after consumption before the first true heartbeat', async () => { + const store = new Store(':memory:') + const io = { + of: () => ({ + to: () => ({ emit() {} }) + }) + } + const engine = new SyncEngine( + store, + io as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + const now = Date.now() + + try { + const session = engine.getOrCreateSession( + 'session-consumed-before-thinking', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + { requests: {}, completedRequests: {} }, + 'default' + ) + engine.handleSessionAlive({ sid: session.id, time: now, thinking: false }) + await engine.sendMessage(session.id, { + text: 'consume before thinking', + localId: 'consumed-local-id' + }) + const activeTurnStartedAt = engine.getSession(session.id)?.activeTurnStartedAt + store.messages.markMessagesInvoked(session.id, ['consumed-local-id'], now + 500) + + engine.handleSessionAlive({ sid: session.id, time: now + 1_000, thinking: false }) + + expect(engine.getSession(session.id)?.thinking).toBe(true) + expect(engine.getSession(session.id)?.activeTurnStartedAt).toBe(activeTurnStartedAt) + } finally { + engine.stop() + } + }) + + it('advances the queued turn boundary on the hub clock across a lagging false heartbeat', () => { const store = new Store(':memory:') const events: SyncEvent[] = [] const cache = new SessionCache(store, createPublisher(events)) @@ -251,19 +345,33 @@ describe('alive incremental events', () => { ) cache.handleSessionAlive({ sid: session.id, time: now, thinking: false }) + store.messages.addMessage( + session.id, + { role: 'user', content: { type: 'text', text: 'queued next prompt' } }, + 'queued-next-local-id' + ) cache.markMessageQueued(session.id, now + 10) + const turnStartedAt = cache.getSession(session.id)?.activeTurnStartedAt events.length = 0 const originalNow = Date.now Date.now = () => now + 2_000 try { - cache.handleSessionAlive({ sid: session.id, time: now + 2_000, thinking: false }) + cache.handleSessionAlive({ sid: session.id, time: now - 3_000, thinking: false }) } finally { Date.now = originalNow } expect(cache.getSession(session.id)?.thinking).toBe(true) - expect(events.find((event) => event.type === 'session-updated')).toBeUndefined() + expect(cache.getSession(session.id)?.activeTurnStartedAt).not.toBe(turnStartedAt) + expect(cache.getSession(session.id)?.activeTurnStartedAt).toBe(now + 2_000) + const update = events.find((event) => event.type === 'session-updated') + expect(update).toBeDefined() + if (!update || update.type !== 'session-updated') return + expect(update.data).toEqual(expect.objectContaining({ + thinking: true, + activeTurnStartedAt: now + 2_000 + })) }) it('clears queued thinking after the grace window expires', () => { diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index a3064bc8..b7ff690c 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -609,7 +609,7 @@ export class MessageService { scheduledAt?: number | null deliveryMode?: MessageDeliveryMode } - ): Promise { + ): Promise<{ actualSessionId: string; createdAt: number }> { // Defence-in-depth invariant for non-REST callers (Telegram bot, MCP, // internal callers). Attachment paths live under the CLI session's // upload directory which `cleanupUploadDir` purges on session end; a @@ -700,7 +700,7 @@ export class MessageService { scheduledAt: msg.scheduledAt } }) - return actualSessionId + return { actualSessionId, createdAt: msg.createdAt } } /** diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 54c94eac..117435d1 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -195,6 +195,7 @@ export class SessionCache { agentStateVersion: stored.agentStateVersion, thinking: existing?.thinking ?? false, thinkingAt: existing?.thinkingAt ?? 0, + activeTurnStartedAt: existing?.activeTurnStartedAt ?? null, backgroundTaskCount: existing?.backgroundTaskCount ?? 0, todos, teamState, @@ -348,6 +349,7 @@ export class SessionCache { const wasActive = session.active const wasThinking = session.thinking + const previousActiveTurnStartedAt = session.activeTurnStartedAt const previousPermissionMode = session.permissionMode const previousModel = session.model const previousModelReasoningEffort = session.modelReasoningEffort @@ -359,11 +361,18 @@ export class SessionCache { const requestedThinking = Boolean(payload.thinking) const hubNow = Date.now() const preserveQueuedThinking = !requestedThinking && pendingThinkingUntil > hubNow + const hasUnconsumedPrompt = preserveQueuedThinking + && this.store.messages.getImmediateQueuedLocalMessages(session.id).length > 0 session.active = true session.activeAt = Math.max(session.activeAt, t) session.thinking = requestedThinking || preserveQueuedThinking session.thinkingAt = t + if (!requestedThinking && preserveQueuedThinking && hasUnconsumedPrompt) { + session.activeTurnStartedAt = hubNow + } else if (wasThinking && !session.thinking) { + session.activeTurnStartedAt = null + } if (requestedThinking || pendingThinkingUntil <= hubNow) { this.pendingThinkingUntilBySessionId.delete(session.id) } @@ -420,8 +429,10 @@ export class SessionCache { || previousServiceTier !== session.serviceTier || previousCollaborationMode !== session.collaborationMode || previousCopilotAgentMode !== session.copilotAgentMode + const turnBoundaryChanged = previousActiveTurnStartedAt !== session.activeTurnStartedAt const shouldBroadcast = (!wasActive && session.active) || (wasThinking !== session.thinking) + || turnBoundaryChanged || modeChanged || (now - lastBroadcastAt > 10_000) @@ -434,6 +445,7 @@ export class SessionCache { active: true, activeAt: session.activeAt, thinking: session.thinking, + activeTurnStartedAt: session.activeTurnStartedAt, permissionMode: session.permissionMode, model: session.model, modelReasoningEffort: session.modelReasoningEffort, @@ -462,7 +474,11 @@ export class SessionCache { this.pendingThinkingUntilBySessionId.delete(sessionId) } - markMessageQueued(sessionId: string, time: number = Date.now()): void { + markMessageQueued( + sessionId: string, + time: number = Date.now(), + activeTurnStartedAt: number = time + ): void { const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) if (!session) return if (!session.active) return @@ -473,6 +489,7 @@ export class SessionCache { session.thinking = true session.thinkingAt = nextTime + if (!wasThinking) session.activeTurnStartedAt = activeTurnStartedAt session.updatedAt = Math.max(session.updatedAt, nextTime) this.pendingThinkingUntilBySessionId.set(session.id, nextTime + QUEUED_MESSAGE_THINKING_GRACE_MS) @@ -483,6 +500,7 @@ export class SessionCache { sessionId: session.id, data: { thinking: true, + activeTurnStartedAt: session.activeTurnStartedAt, updatedAt: session.updatedAt } satisfies SessionPatch }) @@ -582,13 +600,14 @@ export class SessionCache { this.store.sessions.setSessionActive(session.id, false, t, session.namespace) session.thinking = false session.thinkingAt = t + session.activeTurnStartedAt = null session.backgroundTaskCount = 0 this.pendingThinkingUntilBySessionId.delete(session.id) this.publisher.emit({ type: 'session-updated', sessionId: session.id, - data: { active: false, thinking: false, backgroundTaskCount: 0 } satisfies SessionPatch + data: { active: false, thinking: false, activeTurnStartedAt: null, backgroundTaskCount: 0 } satisfies SessionPatch }) } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 9fe4c359..baa526ac 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -924,8 +924,8 @@ async uploadScratchlistAttachment( if (this.historyActionsInFlight.has(sessionId)) { throw new Error('Conversation history action already in progress') } - const actualSessionId = await this.messageService.sendMessage(sessionId, payload) - this.sessionCache.markMessageQueued(actualSessionId) + const { actualSessionId, createdAt: activeTurnStartedAt } = await this.messageService.sendMessage(sessionId, payload) + this.sessionCache.markMessageQueued(actualSessionId, Date.now(), activeTurnStartedAt) this.sessionCache.recordSessionActivity(actualSessionId, Date.now()) } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 9e09252d..f044c24c 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -295,6 +295,7 @@ export const SessionSchema = z.object({ agentStateVersion: z.number(), thinking: z.boolean(), thinkingAt: z.number(), + activeTurnStartedAt: z.number().nullable().optional(), backgroundTaskCount: z.number().optional(), todos: TodosSchema.optional(), teamState: TeamStateSchema.optional(), @@ -347,6 +348,7 @@ const VersionedTeamStatePatchSchema = z.object({ export const SessionPatchSchema = z.object({ active: z.boolean().optional(), thinking: z.boolean().optional(), + activeTurnStartedAt: z.number().nullable().optional(), activeAt: z.number().optional(), updatedAt: z.number().optional(), // Structured-patch fields for the second half of #884. Letting the four diff --git a/web/e2e-fixtures/share-turn-fixture.tsx b/web/e2e-fixtures/share-turn-fixture.tsx index b58197da..01acf032 100644 --- a/web/e2e-fixtures/share-turn-fixture.tsx +++ b/web/e2e-fixtures/share-turn-fixture.tsx @@ -5,6 +5,8 @@ import { ShareTurnDialog } from '../src/components/AssistantChat/ShareTurnDialog import { getUserBubbleClassName, UserBubbleContent } from '../src/components/AssistantChat/messages/user-bubble' import { MarkdownRenderer } from '../src/components/MarkdownRenderer' import { I18nProvider } from '../src/lib/i18n-context' +import { useSessionHeaderMetadata } from '../src/hooks/useSessionHeaderMetadata' +import { selectShareTurnMetadata } from '../src/lib/shareTurnMetadata' const fixtureImage = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(` @@ -73,6 +75,18 @@ function App() { const [snapshots, setSnapshots] = useState([]) const [open, setOpen] = useState(false) const wideSource = new URLSearchParams(window.location.search).get('wide') === '1' + const { preferences: headerMetadata } = useSessionHeaderMetadata() + const metadataItems = selectShareTurnMetadata(headerMetadata, { + agent: { text: 'codex', flavor: 'codex' }, + machine: { text: `${headerMetadata.showLabels ? 'Machine: ' : ''}fixture-host` }, + lastActive: { text: '2 minutes ago' }, + model: { text: `${headerMetadata.showLabels ? 'Model: ' : ''}gpt-5.6-sol` }, + reasoning: { text: `${headerMetadata.showLabels ? 'Reasoning: ' : ''}high` }, + fastMode: { text: 'fast' }, + createdAt: { text: `${headerMetadata.showLabels ? 'Created: ' : ''}Aug 2, 2026, 10:00 AM` }, + updatedAt: { text: `${headerMetadata.showLabels ? 'Updated: ' : ''}Aug 2, 2026, 10:30 AM` }, + worktree: { text: `${headerMetadata.showLabels ? 'Worktree: ' : ''}feat/share-turn-polish` }, + }) const openShare = () => { const searchParams = new URLSearchParams(window.location.search) @@ -130,11 +144,7 @@ function App() { setOpen(false)} diff --git a/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx b/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx index 563c65a4..aadc307c 100644 --- a/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx +++ b/web/src/components/AssistantChat/HappyThread.mobile-scroll.test.tsx @@ -3,6 +3,10 @@ import type { PropsWithChildren } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/lib/i18n-context' +vi.mock('@/hooks/queries/useMachines', () => ({ + useMachines: () => ({ machines: [] }) +})) + vi.mock('@assistant-ui/react', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 06a4d3a4..5e73e439 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -19,10 +19,17 @@ import { useTerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import { useTranslation } from '@/lib/use-translation' import { CloseIcon } from '@/components/icons' import { ShareTurnDialog } from '@/components/AssistantChat/ShareTurnDialog' -import { formatCodexReasoningLabel, shouldShowCodexReasoningLabel } from '@/lib/codexStatusLabels' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { isFastServiceTier } from '@/components/AssistantChat/codexFastMode' import type { OlderLoadOutcome } from '@/lib/message-window-store' +import { useSessionHeaderMetadata } from '@/hooks/useSessionHeaderMetadata' +import { useMachines } from '@/hooks/queries/useMachines' +import { useMachineLabels } from '@/hooks/useMachineLabels' +import { resolveSessionHeaderMachineLabel } from '@/components/SessionHeader' +import { formatRelativeTime } from '@/lib/relativeTime' +import { formatSessionHeaderTimestamp } from '@/lib/sessionHeaderTimestamp' +import { getShareTurnReasoningLabel, selectShareTurnMetadata } from '@/lib/shareTurnMetadata' +import { useMinuteTick } from '@/hooks/useMinuteTick' type ScrollAnchor = { id: string @@ -417,6 +424,7 @@ export function ConversationOutlinePanel(props: { export function HappyThread(props: { api: ApiClient session: Session + serviceTier?: string | null sessionId: string metadata: SessionMetadataSummary | null disabled: boolean @@ -444,14 +452,60 @@ export function HappyThread(props: { onOutlineOpenChange: (open: boolean) => void onOutlineItemClick?: (item: ConversationOutlineItem) => void }) { - const { t } = useTranslation() + const { t, locale } = useTranslation() + const { preferences: headerMetadata } = useSessionHeaderMetadata() + const { machines } = useMachines(props.api, true) + const machineLabelsById = useMachineLabels(machines) + const [shareTurn, setShareTurn] = useState(null) + const shareDialogOpen = shareTurn !== null + const shareRelativeTimeTick = useMinuteTick(headerMetadata.lastActive && shareDialogOpen) + const shareMetadataItems = useMemo(() => { + const agentFlavor = props.session.metadata?.flavor ?? null + const agentLabel = agentFlavor?.trim() || null + const machineLabel = resolveSessionHeaderMachineLabel(props.session, machineLabelsById) + const modelLabel = getSessionModelLabel(props.session) + const reasoningLabel = getShareTurnReasoningLabel( + agentFlavor, + props.session.modelReasoningEffort, + props.session.effort, + headerMetadata.showLabels + ) + const lastActiveAt = props.session.activeAt || props.session.updatedAt || props.session.createdAt + const lastActiveLabel = lastActiveAt > 0 ? formatRelativeTime(lastActiveAt, t) : null + const createdAtLabel = formatSessionHeaderTimestamp(props.session.createdAt, locale) + const updatedAtLabel = formatSessionHeaderTimestamp(props.session.updatedAt, locale) + const worktreeBranch = props.session.metadata?.worktree?.branch?.trim() || null + const showFastBadge = agentFlavor === 'codex' + && isFastServiceTier(props.serviceTier ?? props.session.serviceTier) + + return selectShareTurnMetadata(headerMetadata, { + agent: agentLabel ? { text: agentLabel, flavor: agentFlavor } : undefined, + machine: machineLabel ? { + text: `${headerMetadata.showLabels ? `${t('session.item.machine')}: ` : ''}${machineLabel}`, + } : undefined, + lastActive: lastActiveLabel ? { text: lastActiveLabel } : undefined, + model: modelLabel ? { + text: `${headerMetadata.showLabels ? `${t(modelLabel.key)}: ` : ''}${modelLabel.value}`, + } : undefined, + reasoning: reasoningLabel ? { text: reasoningLabel } : undefined, + fastMode: showFastBadge ? { text: 'fast' } : undefined, + createdAt: createdAtLabel ? { + text: `${headerMetadata.showLabels ? `${t('session.header.createdAt')}: ` : ''}${createdAtLabel}`, + } : undefined, + updatedAt: updatedAtLabel ? { + text: `${headerMetadata.showLabels ? `${t('session.header.updatedAt')}: ` : ''}${updatedAtLabel}`, + } : undefined, + worktree: worktreeBranch ? { + text: `${headerMetadata.showLabels ? `${t('session.item.worktree')}: ` : ''}${worktreeBranch}`, + } : undefined, + }) + }, [headerMetadata, locale, machineLabelsById, props.serviceTier, props.session, shareDialogOpen, shareRelativeTimeTick, t]) const { terminalToolDisplayMode } = useTerminalToolDisplayMode() const runtimeExtras = useAuiState((s) => s.thread.extras) as HappyRuntimeExtras | undefined const appliedMessagesVersion = runtimeExtras?.messagesVersion ?? props.messagesVersion const appliedHistoryVersion = runtimeExtras?.historyVersion ?? props.historyVersion const viewportRef = useRef(null) const contentRef = useRef(null) - const [shareTurn, setShareTurn] = useState(null) const [pullToLoadState, setPullToLoadState] = useState('idle') const pullToLoadStateRef = useRef('idle') const shareTurnIdRef = useRef(0) @@ -1537,16 +1591,7 @@ export function HappyThread(props: { key={shareTurn?.id ?? 'closed'} isOpen={shareTurn !== null} title={shareTurn?.title ?? ''} - flavor={props.session.metadata?.flavor ?? null} - modelLabel={(() => { - const label = getSessionModelLabel(props.session) - return label ? `${t(label.key)}: ${label.value}` : null - })()} - reasoningLabel={shouldShowCodexReasoningLabel(props.session.metadata?.flavor ?? null) - ? formatCodexReasoningLabel(props.session.modelReasoningEffort) - : null} - showFastBadge={props.session.metadata?.flavor === 'codex' && isFastServiceTier(props.session.serviceTier)} - worktreeBranch={props.session.metadata?.worktree?.branch ?? null} + metadataItems={shareMetadataItems} sourceSnapshots={shareTurn?.snapshots ?? []} sourceContentWidth={shareTurn?.sourceContentWidth ?? null} onClose={() => setShareTurn(null)} diff --git a/web/src/components/AssistantChat/ShareTurnDialog.tsx b/web/src/components/AssistantChat/ShareTurnDialog.tsx index d4e87903..178f28a2 100644 --- a/web/src/components/AssistantChat/ShareTurnDialog.tsx +++ b/web/src/components/AssistantChat/ShareTurnDialog.tsx @@ -4,15 +4,12 @@ import { useTranslation } from '@/lib/use-translation' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' import { ZoomableLightbox } from '@/components/ZoomableLightbox' import { safeCopyToClipboard } from '@/lib/clipboard' +import type { ShareTurnMetadataItem } from '@/lib/shareTurnMetadata' type ShareTurnDialogProps = { isOpen: boolean title: string - flavor: string | null - modelLabel: string | null - reasoningLabel: string | null - showFastBadge: boolean - worktreeBranch: string | null + metadataItems: ShareTurnMetadataItem[] sourceSnapshots: Array<{ html: string text: string @@ -610,7 +607,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { return () => { cancelled = true } - }, [props.isOpen, props.sourceSnapshots, ready, restoreTick, previewRevision, exportWidth, preserveSourceLayout]) + }, [props.isOpen, props.sourceSnapshots, props.metadataItems, ready, restoreTick, previewRevision, exportWidth, preserveSourceLayout]) const handlePreviewClick = (event: ReactMouseEvent) => { const target = event.target @@ -757,18 +754,20 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
{props.title}
-
- - - {props.flavor?.trim() || 'unknown'} - - {props.modelLabel ? {props.modelLabel} : null} - {props.reasoningLabel ? {props.reasoningLabel} : null} - {props.showFastBadge ? fast : null} - {props.worktreeBranch ? ( - {t('session.item.worktree')}: {props.worktreeBranch} - ) : null} -
+ {props.metadataItems.length > 0 ? ( +
+ {props.metadataItems.map((item) => item.key === 'agent' ? ( + + + {item.text} + + ) : ( + + {item.text} + + ))} +
+ ) : null}
diff --git a/web/src/components/AssistantChat/messages/MessageActions.tsx b/web/src/components/AssistantChat/messages/MessageActions.tsx index 28cf0a0a..1e5e5f10 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.tsx @@ -8,6 +8,7 @@ import { MessageMetadata, buildMessageMetadataLabels, type MessageMetadataProps import { MessageTimestamp } from './MessageTimestamp' import { cn } from '@/lib/utils' import { ShareTurnButton } from './ShareTurnButton' +import type { HappyRuntimeExtras } from '@/lib/assistant-runtime' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' export type MessageHistoryAction = { @@ -40,7 +41,14 @@ export function MessageActions({ }: MessageActionsProps) { const { copied, copy } = useCopyToClipboard() const { t } = useTranslation() - const threadIsRunning = useAuiState(({ thread }) => thread?.isRunning ?? false) + const { hideShareButton, threadIsRunning } = useAuiState(({ message, thread }) => { + const extras = thread?.extras as HappyRuntimeExtras | undefined + const isRunning = thread?.isRunning ?? false + return { + hideShareButton: extras?.shareHiddenByMessageId.has(message.id) ?? isRunning, + threadIsRunning: isRunning + } + }) const canCopy = Boolean(copyText) const hasMetadata = metadata ? buildMessageMetadataLabels(metadata).length > 0 : false const [forkOpen, setForkOpen] = useState(false) @@ -49,7 +57,7 @@ export function MessageActions({ const [rewindPending, setRewindPending] = useState(false) const actionsLocked = historyActionPending || forkPending || rewindPending || threadIsRunning - const shareButton = messageElementId && !threadIsRunning ? ( + const shareButton = messageElementId && !hideShareButton ? ( { - const timer = window.setInterval(() => { - setRelativeTimeTick((tick) => tick + 1) - }, 60_000) - return () => window.clearInterval(timer) - }, []) + const relativeTimeTick = useMinuteTick(headerMetadata.lastActive) const ageLabel = useMemo( () => (headerMetadata.lastActive && lastActiveAt > 0 ? formatRelativeTime(lastActiveAt, t) : null), [headerMetadata.lastActive, lastActiveAt, t, relativeTimeTick] diff --git a/web/src/hooks/useMinuteTick.test.ts b/web/src/hooks/useMinuteTick.test.ts new file mode 100644 index 00000000..c783761f --- /dev/null +++ b/web/src/hooks/useMinuteTick.test.ts @@ -0,0 +1,41 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useMinuteTick } from './useMinuteTick' + +describe('useMinuteTick', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('refreshes enabled relative labels once a minute', () => { + vi.useFakeTimers() + const { result } = renderHook(() => useMinuteTick(true)) + + expect(result.current).toBe(0) + act(() => vi.advanceTimersByTime(60_000)) + expect(result.current).toBe(1) + }) + + it('does not schedule refreshes when disabled', () => { + vi.useFakeTimers() + const { result } = renderHook(() => useMinuteTick(false)) + + act(() => vi.advanceTimersByTime(120_000)) + expect(result.current).toBe(0) + }) + + it('starts refreshing only after it becomes enabled', () => { + vi.useFakeTimers() + const { result, rerender } = renderHook( + ({ enabled }) => useMinuteTick(enabled), + { initialProps: { enabled: false } } + ) + + act(() => vi.advanceTimersByTime(60_000)) + expect(result.current).toBe(0) + + rerender({ enabled: true }) + act(() => vi.advanceTimersByTime(60_000)) + expect(result.current).toBe(1) + }) +}) diff --git a/web/src/hooks/useMinuteTick.ts b/web/src/hooks/useMinuteTick.ts new file mode 100644 index 00000000..49d3e1b9 --- /dev/null +++ b/web/src/hooks/useMinuteTick.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react' + +export function useMinuteTick(enabled = true): number { + const [tick, setTick] = useState(0) + + useEffect(() => { + if (!enabled) return + + const timer = window.setInterval(() => { + setTick((value) => value + 1) + }, 60_000) + return () => window.clearInterval(timer) + }, [enabled]) + + return tick +} diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index 3105d43b..2e12dcec 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -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 }> 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(() => ({ 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 diff --git a/web/src/lib/shareTurnAvailability.test.ts b/web/src/lib/shareTurnAvailability.test.ts new file mode 100644 index 00000000..bdd44623 --- /dev/null +++ b/web/src/lib/shareTurnAvailability.test.ts @@ -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) + }) +}) diff --git a/web/src/lib/shareTurnAvailability.ts b/web/src/lib/shareTurnAvailability.ts new file mode 100644 index 00000000..7fee3233 --- /dev/null +++ b/web/src/lib/shareTurnAvailability.ts @@ -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 { + if (!threadIsRunning) return new Set() + + const activeUserIndex = messages.findLastIndex(isShareTurnUserMessage) + const hidden = new Set() + 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 +} diff --git a/web/src/lib/shareTurnMetadata.test.ts b/web/src/lib/shareTurnMetadata.test.ts new file mode 100644 index 00000000..4918476e --- /dev/null +++ b/web/src/lib/shareTurnMetadata.test.ts @@ -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') + }) +}) diff --git a/web/src/lib/shareTurnMetadata.ts b/web/src/lib/shareTurnMetadata.ts new file mode 100644 index 00000000..59a0577a --- /dev/null +++ b/web/src/lib/shareTurnMetadata.ts @@ -0,0 +1,42 @@ +import type { SessionHeaderMetadataPreferences } from '@/hooks/useSessionHeaderMetadata' +import { formatReasoningLabel, getReasoningEffortForFlavor } from '@/lib/codexStatusLabels' + +export type ShareTurnMetadataKey = Exclude + +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 = [ + 'agent', + 'machine', + 'lastActive', + 'model', + 'reasoning', + 'fastMode', + 'createdAt', + 'updatedAt', + 'worktree', +] + +export function selectShareTurnMetadata( + preferences: SessionHeaderMetadataPreferences, + available: Partial>> +): ShareTurnMetadataItem[] { + return SESSION_HEADER_METADATA_ORDER.flatMap((key) => { + const item = available[key] + return preferences[key] && item?.text ? [{ key, ...item }] : [] + }) +}