fix(web): polish shared turn image UX (#1208)

* feat(web): include session title in share image filename

* fix(web): polish shared turn image UX
This commit is contained in:
Ananovo
2026-07-28 19:40:02 +08:00
committed by GitHub
parent a469d66bc4
commit 5f367dcb6a
9 changed files with 207 additions and 68 deletions
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { ThreadPrimitive, useAssistantState } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client'
import type { HappyRuntimeExtras } from '@/lib/assistant-runtime'
import type { SessionMetadataSummary } from '@/types/api'
import type { Session, SessionMetadataSummary } from '@/types/api'
import type { ConversationOutlineItem } from '@/chat/outline'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { formatMessageTimestampTitle, formatOutlineTimestamp } from '@/chat/presentation'
@@ -16,6 +16,9 @@ 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'
type ScrollAnchor = {
id: string
@@ -33,7 +36,6 @@ type ShareTurnState = {
id: number
snapshots: ShareTurnSnapshot[]
title: string
subtitle: string
} | null
type ShareTurnSnapshot = {
@@ -367,6 +369,7 @@ export function ConversationOutlinePanel(props: {
export function HappyThread(props: {
api: ApiClient
session: Session
sessionId: string
metadata: SessionMetadataSummary | null
disabled: boolean
@@ -843,7 +846,6 @@ export function HappyThread(props: {
id: ++shareTurnIdRef.current,
snapshots: fallbackSnapshot ? [fallbackSnapshot] : [],
title: props.metadata?.summary?.text ?? props.metadata?.name ?? props.metadata?.path ?? props.sessionId.slice(0, 8),
subtitle: [props.metadata?.flavor, props.metadata?.host].filter(Boolean).join(' · ') || props.sessionId
})
return
}
@@ -887,7 +889,6 @@ export function HappyThread(props: {
id: ++shareTurnIdRef.current,
snapshots: completeSnapshots,
title: props.metadata?.summary?.text ?? props.metadata?.name ?? props.metadata?.path ?? props.sessionId.slice(0, 8),
subtitle: [props.metadata?.flavor, props.metadata?.host].filter(Boolean).join(' · ') || props.sessionId
})
}, [props.metadata, props.sessionId])
@@ -982,7 +983,16 @@ export function HappyThread(props: {
key={shareTurn?.id ?? 'closed'}
isOpen={shareTurn !== null}
title={shareTurn?.title ?? ''}
subtitle={shareTurn?.subtitle ?? ''}
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}
sourceSnapshots={shareTurn?.snapshots ?? []}
onClose={() => setShareTurn(null)}
/>
@@ -1,11 +1,16 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useTranslation } from '@/lib/use-translation'
import { AgentFlavorIcon } from '@/components/AgentFlavorIcon'
type ShareTurnDialogProps = {
isOpen: boolean
title: string
subtitle: string
flavor: string | null
modelLabel: string | null
reasoningLabel: string | null
showFastBadge: boolean
worktreeBranch: string | null
sourceSnapshots: Array<{
html: string
text: string
@@ -19,6 +24,7 @@ type ShareTurnSnapshot = ShareTurnDialogProps['sourceSnapshots'][number]
const SHARE_EXPORT_WIDTH = 960
const SHARE_EXPORT_SCALE = 2
const MAX_EXPORT_PIXELS = 24_000_000
const SHARE_HIDDEN_CONTENT_SELECTOR = '[data-hapi-share-exclude="true"], .aui-reasoning-group'
function nextFrame(): Promise<void> {
return new Promise((resolve) => {
@@ -27,8 +33,25 @@ function nextFrame(): Promise<void> {
}
function stripCaptureOnlyControls(root: HTMLElement): void {
for (const element of Array.from(root.querySelectorAll('[data-hapi-share-exclude="true"], .aui-reasoning-group'))) {
element.remove()
for (const element of Array.from(root.querySelectorAll(SHARE_HIDDEN_CONTENT_SELECTOR))) {
if (!(element instanceof HTMLElement) || !root.contains(element)) continue
let previous = element.previousElementSibling
while (previous?.matches(SHARE_HIDDEN_CONTENT_SELECTOR)) previous = previous.previousElementSibling
let next = element.nextElementSibling
while (next?.matches(SHARE_HIDDEN_CONTENT_SELECTOR)) next = next.nextElementSibling
const separatesVisibleContent = previous != null
&& next != null
&& !previous.matches('.hapi-share-hidden-content-spacer')
if (separatesVisibleContent) {
const spacer = document.createElement('div')
spacer.className = 'hapi-share-hidden-content-spacer'
spacer.setAttribute('aria-hidden', 'true')
element.replaceWith(spacer)
} else {
element.remove()
}
}
for (const element of Array.from(root.querySelectorAll('.happy-message-actions, .happy-message-actions-first-line, [data-hapi-share-action="true"], button[aria-expanded], input, textarea, select'))) {
element.remove()
@@ -38,6 +61,11 @@ function stripCaptureOnlyControls(root: HTMLElement): void {
anchor.removeAttribute('target')
anchor.removeAttribute('rel')
}
for (const imageButton of Array.from(root.querySelectorAll('button:has(img)'))) {
imageButton.removeAttribute('title')
imageButton.removeAttribute('aria-label')
imageButton.setAttribute('tabindex', '-1')
}
for (const element of Array.from(root.querySelectorAll('[role="button"], [contenteditable="true"]'))) {
if (element.tagName.toLowerCase() !== 'a') {
element.removeAttribute('role')
@@ -59,8 +87,24 @@ function formatShareTimestamp(date = new Date()): string {
].join('')
}
function getShareFileName(): string {
return `HAPI-${formatShareTimestamp()}.png`
function sanitizeShareFileNamePart(title: string): string {
const withoutControlCharacters = Array.from(title.normalize('NFKC'))
.filter((character) => {
const codePoint = character.codePointAt(0) ?? 0
return codePoint >= 32 && codePoint !== 127
})
.join('')
const sanitized = withoutControlCharacters
.replace(/[<>:"/\\|?*]+/g, '-')
.replace(/\s+/g, ' ')
.replace(/-+/g, '-')
.replace(/^[ .-]+|[ .-]+$/g, '')
.trim()
return Array.from(sanitized || 'Shared turn').slice(0, 80).join('').trim()
}
function getShareFileName(title: string): string {
return `HAPI-${sanitizeShareFileNamePart(title)}-${formatShareTimestamp()}.png`
}
function prepareExportElement(element: HTMLElement): HTMLElement {
@@ -151,11 +195,28 @@ function prepareExportElement(element: HTMLElement): HTMLElement {
.hapi-share-export-root .sr-only {
display: none !important;
}
.hapi-share-export-root .hapi-share-hidden-content-spacer {
display: block !important;
height: 0.75rem !important;
}
.hapi-share-export-root pre,
.hapi-share-export-root code {
.hapi-share-export-root .aui-md-codeblockcode {
white-space: pre-wrap !important;
overflow-wrap: anywhere !important;
}
.hapi-share-export-root .aui-md-code:not(.aui-md-codeblockcode) {
display: inline-block !important;
width: auto !important;
max-width: 100% !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
direction: ltr !important;
unicode-bidi: isolate !important;
text-align: left !important;
vertical-align: baseline !important;
padding-left: 0.2em !important;
padding-right: 0.2em !important;
}
`
captureElement.prepend(style)
return captureElement
@@ -233,7 +294,6 @@ function copyLoadedStyleSheets(source: Document, target: Document): void {
// network fallback; same-origin app CSS always takes the inline path.
}
}
for (const node of Array.from(source.head.querySelectorAll('link[rel="stylesheet"], style'))) {
if (copiedOwners.has(node)) continue
const clone = node.cloneNode(true)
@@ -379,8 +439,8 @@ function downloadBlob(blob: Blob, fileName: string): void {
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
}
async function shareImageBlob(blob: Blob): Promise<void> {
const file = new File([blob], getShareFileName(), { type: blob.type })
async function shareImageBlob(blob: Blob, fileName: string): Promise<void> {
const file = new File([blob], fileName, { type: blob.type })
if (!navigator.share) {
throw new Error('Image sharing is not supported in this browser')
}
@@ -506,9 +566,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
try {
const blob = await elementToPngBlob(capture)
await action(blob)
if (mode === 'copy') {
setCopied(true)
}
if (mode === 'copy') setCopied(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create image')
} finally {
@@ -518,24 +576,57 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
return (
<Dialog open={props.isOpen} onOpenChange={(open) => { if (!open) props.onClose() }}>
<DialogContent className="max-h-[calc(100vh-24px)] max-w-3xl overflow-hidden p-4" aria-describedby={undefined}>
<DialogHeader>
<DialogContent
className="max-h-[calc(100vh-24px)] max-w-3xl overflow-hidden p-4 [&>button:last-child]:top-4"
aria-describedby={undefined}
>
<DialogHeader className="h-8 justify-center !px-10 text-center sm:text-center">
<DialogTitle>{t('shareTurn.title')}</DialogTitle>
</DialogHeader>
<div className="mt-3 max-h-[58vh] overflow-auto rounded-2xl border border-[var(--app-border)] bg-[var(--app-bg)] p-2 sm:max-h-[65vh] sm:p-3">
<div
ref={captureRef}
className="mx-auto w-[720px] max-w-full rounded-[28px] bg-[var(--app-bg)] p-4 text-[var(--app-fg)] sm:p-5"
className="hapi-share-preview-root mx-auto w-[720px] max-w-full rounded-[28px] bg-[var(--app-bg)] p-4 text-[var(--app-fg)] sm:p-5"
>
<div className="mb-4 flex items-start justify-between gap-3 border-b border-[var(--app-divider)] pb-3">
<style>{`
.hapi-share-preview-root .hapi-share-hidden-content-spacer {
display: block !important;
height: 0.75rem !important;
}
.hapi-share-preview-root .hapi-share-media-grid:not([data-hapi-image-count="1"]) {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
align-items: start !important;
width: 100% !important;
}
.hapi-share-preview-root .hapi-share-media-grid:not([data-hapi-image-count="1"]) > button,
.hapi-share-preview-root .hapi-share-media-grid:not([data-hapi-image-count="1"]) > button > img {
width: 100% !important;
min-width: 0 !important;
}
.hapi-share-preview-root .hapi-share-media-grid > button {
height: auto !important;
align-self: start !important;
cursor: default !important;
pointer-events: none !important;
}
`}</style>
<div className="mb-4 border-b border-[var(--app-divider)] pb-3">
<div className="min-w-0">
<div className="text-lg font-semibold">HAPI</div>
<div className="mt-1 truncate text-xs text-[var(--app-hint)]">{props.title}</div>
<div className="mt-0.5 truncate text-xs text-[var(--app-hint)]">{props.subtitle}</div>
</div>
<div className="rounded-full border border-[var(--app-border)] px-2 py-1 text-[10px] text-[var(--app-hint)]">
{t('shareTurn.badge')}
<div className="truncate text-lg font-semibold">{props.title}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-[var(--app-hint)]">
<span className="inline-flex items-center gap-1">
<AgentFlavorIcon flavor={props.flavor} className="h-3.5 w-3.5 shrink-0" />
{props.flavor?.trim() || 'unknown'}
</span>
{props.modelLabel ? <span>{props.modelLabel}</span> : null}
{props.reasoningLabel ? <span>{props.reasoningLabel}</span> : null}
{props.showFastBadge ? <span className="text-[#34C759]">fast</span> : null}
{props.worktreeBranch ? (
<span>{t('session.item.worktree')}: {props.worktreeBranch}</span>
) : null}
</div>
</div>
</div>
<div ref={bodyRef} data-hapi-share-body="true" className="flex flex-col gap-3" />
@@ -550,26 +641,25 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
) : null}
<div className="mt-4 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:justify-end">
<button
type="button"
onClick={props.onClose}
className="rounded-md border border-[var(--app-border)] px-3 py-2 text-sm text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] sm:w-32"
>
{t('shareTurn.cancel')}
</button>
<button
type="button"
onClick={() => { void withPng(copyImageBlob, 'copy') }}
disabled={busy !== null || !ready}
className="hidden rounded-md border border-[var(--app-border)] px-3 py-2 text-sm text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] disabled:opacity-50 sm:inline-block sm:w-32"
>
{busy === 'copy' ? t('shareTurn.copying') : copied ? t('shareTurn.copied') : t('shareTurn.copy')}
{copied ? t('shareTurn.copied') : t('shareTurn.copy')}
</button>
{showNativeShareButton ? (
<button
type="button"
onClick={() => {
if (preparedBlob) runBlobAction(preparedBlob, shareImageBlob, 'share')
if (preparedBlob) {
runBlobAction(
preparedBlob,
(blob) => shareImageBlob(blob, getShareFileName(props.title)),
'share'
)
}
}}
disabled={busy !== null || !preparedBlob}
className="rounded-md border border-[var(--app-border)] px-3 py-2 text-sm text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] disabled:opacity-50 sm:w-32"
@@ -580,10 +670,10 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
<button
type="button"
onClick={() => {
void withPng((blob) => downloadBlob(blob, getShareFileName()), 'download')
void withPng((blob) => downloadBlob(blob, getShareFileName(props.title)), 'download')
}}
disabled={busy !== null || !ready}
className="col-span-2 rounded-md bg-[var(--app-button)] px-3 py-2 text-sm text-[var(--app-button-text)] disabled:opacity-50 sm:col-span-1 sm:w-32"
className="rounded-md bg-[var(--app-button)] px-3 py-2 text-sm text-[var(--app-button-text)] disabled:opacity-50 sm:w-32"
>
{busy === 'download' ? t('shareTurn.saving') : t('shareTurn.download')}
</button>
@@ -1,4 +1,5 @@
import * as Popover from '@radix-ui/react-popover'
import { useAssistantState } from '@assistant-ui/react'
import { CheckIcon, CopyIcon, InfoIcon } from '@/components/icons'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { useTranslation } from '@/lib/use-translation'
@@ -17,8 +18,16 @@ type MessageActionsProps = {
export function MessageActions({ align, copyText, metadata, messageElementId }: MessageActionsProps) {
const { copied, copy } = useCopyToClipboard()
const { t } = useTranslation()
const threadIsRunning = useAssistantState(({ thread }) => thread?.isRunning ?? false)
const canCopy = Boolean(copyText)
const hasMetadata = metadata ? buildMessageMetadataLabels(metadata).length > 0 : false
const shareButton = messageElementId && !threadIsRunning ? (
<ShareTurnButton
messageElementId={messageElementId}
fallbackText={copyText}
className="flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
/>
) : null
return (
<div
@@ -28,6 +37,8 @@ export function MessageActions({ align, copyText, metadata, messageElementId }:
)}
>
{align === 'end' ? <DesktopTimestamp /> : null}
{align === 'end' && hasMetadata && metadata ? <MessageInfoPopover metadata={metadata} /> : null}
{align === 'end' ? shareButton : null}
{canCopy ? (
<button
type="button"
@@ -39,14 +50,8 @@ export function MessageActions({ align, copyText, metadata, messageElementId }:
{copied ? <CheckIcon className="h-3.5 w-3.5 text-green-500" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null}
{hasMetadata && metadata ? <MessageInfoPopover metadata={metadata} /> : null}
{messageElementId ? (
<ShareTurnButton
messageElementId={messageElementId}
fallbackText={copyText}
className="flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]"
/>
) : null}
{align === 'start' ? shareButton : null}
{align === 'start' && hasMetadata && metadata ? <MessageInfoPopover metadata={metadata} /> : null}
{align === 'start' ? <DesktopTimestamp /> : null}
</div>
)
@@ -56,7 +56,10 @@ export function MessageAttachments(props: { attachments: AttachmentMetadata[] })
return (
<div className="mt-2 flex flex-col gap-2">
{images.length > 0 && (
<div className="flex flex-wrap gap-2">
<div
className="hapi-share-media-grid flex flex-wrap gap-2"
data-hapi-image-count={images.length}
>
{images.map(attachment => (
<ImageAttachment key={attachment.id} attachment={attachment} />
))}
+1
View File
@@ -1253,6 +1253,7 @@ function SessionChatInner(props: SessionChatProps) {
// Without prefixes, React may reuse the wrong component's DOM/localStorage.
key={`thread-${props.session.id}`}
api={props.api}
session={props.session}
sessionId={props.session.id}
metadata={props.session.metadata}
disabled={sessionInactive}