mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): FUE + composer hint for session @-mentions (#1274)
* feat(web): FUE + placeholder for rich composer session @-mentions Discover session @-mentions via composer-grounded FUE and always-on placeholder copy when rich composer is active (#1273). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): notify onFocus after programmatic rich-composer autofocus Playwright headless (and some engines) skip the DOM focus event for element.focus(), so FUE engage never ran. Call the onFocus prop after autofocus so discovery still works. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): engage rich-composer FUE on mount DOM focus events are unreliable for programmatic autofocus (and in Playwright). Treat the live rich composer as the affordance and open the callout when the rich path mounts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): measure FueCallout height; ellipsis rich placeholder Bot review on #1274: position from real panel height (ResizeObserver) so multi-line FUE bodies clear the composer, and keep long mention placeholders on one ellipsized line in the input row. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): Escape dismisses rich-composer FUE; keep expand flex chain Escape while the mention FUE is engaging only dismisses the callout (no abort/collapse). FUE anchor is a flex container so expanded RichComposerInput still fills height. Mock resolveComposerPlaceholderKey in sendError tests. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -83,7 +83,11 @@ vi.mock('@assistant-ui/react', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/composerSegments', () => ({ isRichComposerMentionsEnabled: () => false }))
|
||||
vi.mock('@/lib/composerSegments', () => ({
|
||||
isRichComposerMentionsEnabled: () => false,
|
||||
resolveComposerPlaceholderKey: ({ showContinueHint }: { showContinueHint: boolean }) =>
|
||||
showContinueHint ? 'misc.typeMessage' : 'misc.typeAMessage',
|
||||
}))
|
||||
vi.mock('@/hooks/useComposerDraft', () => ({
|
||||
useComposerDraft: (sessionId: string | undefined) => ({ sessionId, complete: true, restoredAny: false }),
|
||||
}))
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { isRichComposerMentionsEnabled } from '@/lib/composerSegments'
|
||||
import { isRichComposerMentionsEnabled, resolveComposerPlaceholderKey } from '@/lib/composerSegments'
|
||||
import type { SessionMentionResolveResult } from '@/components/AssistantChat/RichComposerInput'
|
||||
import {
|
||||
RichComposerInput,
|
||||
type RichComposerInputHandle,
|
||||
} from '@/components/AssistantChat/RichComposerInput'
|
||||
import { useFue } from '@/lib/use-fue'
|
||||
import { FueCallout, FueDot } from '@/components/Fue'
|
||||
import type { AgentState, CodexCollaborationMode, PermissionMode, PiModelSummary } from '@/types/api'
|
||||
import type { Suggestion } from '@/hooks/useActiveSuggestions'
|
||||
import type { ConversationStatus } from '@/realtime/types'
|
||||
@@ -522,6 +524,7 @@ export function HappyComposer(props: {
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const richInputRef = useRef<RichComposerInputHandle>(null)
|
||||
const richComposerFueAnchorRef = useRef<HTMLDivElement>(null)
|
||||
// `composer.text === ''` alone is not enough to identify the empty state
|
||||
// created by a send. A user can type and delete a fresh draft before the
|
||||
// failed mutation reports back. Keep monotonic interaction generations so
|
||||
@@ -538,8 +541,21 @@ export function HappyComposer(props: {
|
||||
// Kill-switch only (?richMentions=0 / localStorage=0 / VITE=false). Mount-time
|
||||
// read — hard reload required, so no per-keystroke localStorage/URL parse.
|
||||
const [richMentionsEnabled] = useState(() => isRichComposerMentionsEnabled())
|
||||
const {
|
||||
status: richComposerFueStatus,
|
||||
engage: engageRichComposerFue,
|
||||
dismiss: dismissRichComposerFue,
|
||||
} = useFue('rich-composer-mentions')
|
||||
const prevControlledByUser = useRef(controlledByUser)
|
||||
|
||||
// Composer itself is the affordance: open the FUE callout once the rich
|
||||
// path is live. Relying on DOM focus alone is flaky (programmatic
|
||||
// autofocus / Playwright headless often skip the focus event).
|
||||
useEffect(() => {
|
||||
if (!richMentionsEnabled) return
|
||||
engageRichComposerFue()
|
||||
}, [richMentionsEnabled, engageRichComposerFue])
|
||||
|
||||
const recordUserEdit = useCallback(() => {
|
||||
userEditGenerationRef.current += 1
|
||||
}, [])
|
||||
@@ -1178,6 +1194,14 @@ export function HappyComposer(props: {
|
||||
}
|
||||
|
||||
if (key === 'Escape') {
|
||||
// FUE callout also listens on window; dismiss it first so Escape
|
||||
// does not also abort a running thread or collapse the editor.
|
||||
if (richComposerFueStatus === 'engaging') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dismissRichComposerFue()
|
||||
return
|
||||
}
|
||||
const action = getComposerEscapeAction({
|
||||
hasSuggestions: suggestions.length > 0,
|
||||
threadIsRunning,
|
||||
@@ -1217,6 +1241,8 @@ export function HappyComposer(props: {
|
||||
haptic,
|
||||
composerEnterBehavior,
|
||||
richMentionsEnabled,
|
||||
richComposerFueStatus,
|
||||
dismissRichComposerFue,
|
||||
flushAndSend,
|
||||
canQueueSend,
|
||||
isExpanded,
|
||||
@@ -2058,20 +2084,36 @@ export function HappyComposer(props: {
|
||||
isExpanded ? 'min-h-0 flex-1 items-stretch' : 'items-center'
|
||||
}`}>
|
||||
{richMentionsEnabled ? (
|
||||
<RichComposerInput
|
||||
ref={richInputRef}
|
||||
value={composerText}
|
||||
autoFocus={!controlsDisabled && !isTouch}
|
||||
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
|
||||
disabled={controlsDisabled}
|
||||
onValueChange={handleRichValueChange}
|
||||
onMirrorChange={handleRichMirrorChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
resolveSessionMentionTooltip={resolveSessionMentionTooltip}
|
||||
onEdit={handleRichEdit}
|
||||
className={editorClassName}
|
||||
/>
|
||||
<div
|
||||
ref={richComposerFueAnchorRef}
|
||||
className="relative flex min-w-0 flex-1"
|
||||
data-testid="rich-composer-fue-anchor"
|
||||
>
|
||||
<RichComposerInput
|
||||
ref={richInputRef}
|
||||
value={composerText}
|
||||
autoFocus={!controlsDisabled && !isTouch}
|
||||
placeholder={t(resolveComposerPlaceholderKey({
|
||||
richMentionsEnabled: true,
|
||||
showContinueHint,
|
||||
}))}
|
||||
disabled={controlsDisabled}
|
||||
onValueChange={handleRichValueChange}
|
||||
onMirrorChange={handleRichMirrorChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onFocus={() => engageRichComposerFue()}
|
||||
resolveSessionMentionTooltip={resolveSessionMentionTooltip}
|
||||
onEdit={handleRichEdit}
|
||||
className={editorClassName}
|
||||
/>
|
||||
{richComposerFueStatus !== 'acknowledged' ? (
|
||||
<FueDot
|
||||
pulsing={richComposerFueStatus === 'unseen'}
|
||||
ariaLabel={t('fue.newFeatureDot')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : isExpanded ? (
|
||||
<ComposerPrimitive.Input
|
||||
asChild
|
||||
@@ -2085,7 +2127,10 @@ export function HappyComposer(props: {
|
||||
onPaste={handlePaste}
|
||||
>
|
||||
<textarea
|
||||
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
|
||||
placeholder={t(resolveComposerPlaceholderKey({
|
||||
richMentionsEnabled: false,
|
||||
showContinueHint,
|
||||
}))}
|
||||
disabled={controlsDisabled}
|
||||
className="h-full min-h-0 flex-1 resize-none overflow-y-auto bg-transparent text-base leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
@@ -2094,7 +2139,10 @@ export function HappyComposer(props: {
|
||||
<ComposerPrimitive.Input
|
||||
ref={textareaRef}
|
||||
autoFocus={!controlsDisabled && !isTouch}
|
||||
placeholder={showContinueHint ? t('misc.typeMessage') : t('misc.typeAMessage')}
|
||||
placeholder={t(resolveComposerPlaceholderKey({
|
||||
richMentionsEnabled: false,
|
||||
showContinueHint,
|
||||
}))}
|
||||
disabled={controlsDisabled}
|
||||
maxRows={5}
|
||||
submitOnEnter={false}
|
||||
@@ -2107,6 +2155,17 @@ export function HappyComposer(props: {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{richMentionsEnabled && richComposerFueStatus === 'engaging' ? (
|
||||
<FueCallout
|
||||
title={t('richComposer.fueTitle')}
|
||||
body={t('richComposer.fueBody')}
|
||||
onDismiss={dismissRichComposerFue}
|
||||
dismissLabel={t('fue.gotIt')}
|
||||
closeAriaLabel={t('fue.closeAriaLabel')}
|
||||
anchorRef={richComposerFueAnchorRef}
|
||||
width={288}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ComposerButtons
|
||||
canSend={canSend}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type FocusEvent as ReactFocusEvent,
|
||||
type FormEvent as ReactFormEvent,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
@@ -72,6 +73,7 @@ type Props = {
|
||||
onMirrorChange: (state: { text: string; selection: ComposerSelection }) => void
|
||||
onKeyDown?: (e: ReactKeyboardEvent<HTMLDivElement>) => void
|
||||
onPaste?: (e: ReactClipboardEvent<HTMLDivElement>) => void
|
||||
onFocus?: (e: ReactFocusEvent<HTMLDivElement>) => void
|
||||
onEdit?: () => void
|
||||
/** Live session meta for chip hover / aria-label (from useSessions). */
|
||||
resolveSessionMentionTooltip?: ResolveSessionMentionTooltip
|
||||
@@ -619,6 +621,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
onMirrorChange,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onFocus,
|
||||
onEdit,
|
||||
resolveSessionMentionTooltip,
|
||||
},
|
||||
@@ -683,6 +686,9 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
syncFromValue(value)
|
||||
}, [value, syncFromValue])
|
||||
|
||||
const onFocusRef = useRef(onFocus)
|
||||
onFocusRef.current = onFocus
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || disabled) return
|
||||
const root = rootRef.current
|
||||
@@ -692,6 +698,18 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
} catch {
|
||||
root.focus()
|
||||
}
|
||||
// Programmatic focus is not guaranteed to fire a DOM focus event in
|
||||
// every engine (notably Playwright headless). Notify the parent so
|
||||
// FUE / other first-focus hooks still run.
|
||||
onFocusRef.current?.(
|
||||
{
|
||||
type: 'focus',
|
||||
target: root,
|
||||
currentTarget: root,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
} as ReactFocusEvent<HTMLDivElement>
|
||||
)
|
||||
}, [autoFocus, disabled])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -995,7 +1013,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
{domIsEmpty && placeholder ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 text-base leading-snug text-[var(--app-hint)]"
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden text-ellipsis whitespace-nowrap text-base leading-snug text-[var(--app-hint)]"
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
@@ -1013,6 +1031,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
data-testid="rich-composer-input"
|
||||
className={`${className ?? ''}${disabled ? ' cursor-not-allowed opacity-50' : ''}`}
|
||||
onInput={handleInput}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerOver={handlePointerOver}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
|
||||
@@ -68,4 +68,17 @@ describe('computeFueCalloutPlacement', () => {
|
||||
expect(result.placement).toBe('above')
|
||||
expect(result.top).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('uses measured panelHeight so taller callouts clear the anchor', () => {
|
||||
const result = computeFueCalloutPlacement({
|
||||
anchor: { top: 700, left: 100, right: 132, bottom: 732 },
|
||||
panelWidth: 288,
|
||||
panelHeight: 180,
|
||||
viewport: VIEWPORT,
|
||||
gap: 8,
|
||||
})
|
||||
expect(result.placement).toBe('above')
|
||||
// top = anchor.top - gap - panelHeight = 700 - 8 - 180 = 512
|
||||
expect(result.top).toBe(512)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties, ReactNode, RefObject } from 'react'
|
||||
import { useEffect, useLayoutEffect, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/**
|
||||
@@ -112,6 +112,7 @@ export function FueCallout(props: {
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
const panelWidth = props.width ?? 256
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{
|
||||
top: number
|
||||
left: number
|
||||
@@ -127,9 +128,11 @@ export function FueCallout(props: {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [props])
|
||||
|
||||
// Position once the anchor is mounted; re-position on viewport changes.
|
||||
// useLayoutEffect so first paint already has correct position
|
||||
// (avoids a flash at 0,0).
|
||||
// Position once the anchor is mounted; re-position on viewport / panel size
|
||||
// changes. useLayoutEffect so first paint already has correct position
|
||||
// (avoids a flash at 0,0). Measure the real panel height — multi-line FUE
|
||||
// bodies are taller than the old 96px estimate and would overlap the
|
||||
// composer if we kept the hard-code.
|
||||
useLayoutEffect(() => {
|
||||
const anchor = props.anchorRef.current
|
||||
if (!anchor) return
|
||||
@@ -139,9 +142,8 @@ export function FueCallout(props: {
|
||||
if (!a) return
|
||||
const rect = a.getBoundingClientRect()
|
||||
const vp = window.visualViewport
|
||||
// Estimate panel height before render — close enough for clamping;
|
||||
// the actual layout adapts via Tailwind classes anyway.
|
||||
const panelHeight = 96
|
||||
const measured = panelRef.current?.getBoundingClientRect().height ?? 0
|
||||
const panelHeight = measured > 0 ? measured : 96
|
||||
setPos(
|
||||
computeFueCalloutPlacement({
|
||||
anchor: rect,
|
||||
@@ -157,22 +159,30 @@ export function FueCallout(props: {
|
||||
)
|
||||
}
|
||||
measure()
|
||||
const panel = panelRef.current
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined' && panel
|
||||
? new ResizeObserver(() => measure())
|
||||
: null
|
||||
if (panel && resizeObserver) resizeObserver.observe(panel)
|
||||
window.addEventListener('resize', measure, { passive: true })
|
||||
window.addEventListener('scroll', measure, { passive: true, capture: true })
|
||||
window.visualViewport?.addEventListener('resize', measure, { passive: true })
|
||||
window.visualViewport?.addEventListener('scroll', measure, { passive: true })
|
||||
return () => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', measure)
|
||||
window.removeEventListener('scroll', measure, true)
|
||||
window.visualViewport?.removeEventListener('resize', measure)
|
||||
window.visualViewport?.removeEventListener('scroll', measure)
|
||||
}
|
||||
}, [props.anchorRef, panelWidth])
|
||||
}, [props.anchorRef, panelWidth, props.title, props.body])
|
||||
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const node = (
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={props.title}
|
||||
style={
|
||||
@@ -184,7 +194,7 @@ export function FueCallout(props: {
|
||||
width: panelWidth,
|
||||
...props.style,
|
||||
}
|
||||
: { position: 'fixed', visibility: 'hidden' }
|
||||
: { position: 'fixed', visibility: 'hidden', width: panelWidth }
|
||||
}
|
||||
// Solid theme-aware bg + solid amber border. The badge-warning CSS
|
||||
// vars are alpha 0.2 (designed to layer over chat content); using
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
insertSegmentsInComposerSegments,
|
||||
insertSessionMentionInComposerSegments,
|
||||
isRichComposerMentionsEnabled,
|
||||
resolveComposerPlaceholderKey,
|
||||
mirrorComposerSegments,
|
||||
parseComposerSegments,
|
||||
serializeComposerSegments,
|
||||
@@ -224,6 +225,29 @@ describe('isRichComposerMentionsEnabled', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveComposerPlaceholderKey', () => {
|
||||
it('prefers continue hint over mention copy', () => {
|
||||
expect(resolveComposerPlaceholderKey({
|
||||
richMentionsEnabled: true,
|
||||
showContinueHint: true,
|
||||
})).toBe('misc.typeMessage')
|
||||
})
|
||||
|
||||
it('uses mention-aware placeholder when rich composer is on', () => {
|
||||
expect(resolveComposerPlaceholderKey({
|
||||
richMentionsEnabled: true,
|
||||
showContinueHint: false,
|
||||
})).toBe('misc.typeAMessageWithMentions')
|
||||
})
|
||||
|
||||
it('keeps generic placeholder when rich composer is killed', () => {
|
||||
expect(resolveComposerPlaceholderKey({
|
||||
richMentionsEnabled: false,
|
||||
showContinueHint: false,
|
||||
})).toBe('misc.typeAMessage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeComposerSelection', () => {
|
||||
it('emits wire markdown with session ids for a chip selection', () => {
|
||||
const segments = parseComposerSegments('see [Peer A](/sessions/aaa) please')
|
||||
|
||||
@@ -303,3 +303,16 @@ export function isRichComposerMentionsEnabled(): boolean {
|
||||
if (import.meta.env.VITE_RICH_COMPOSER_MENTIONS === 'false') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer empty-state placeholder i18n key.
|
||||
* Continue-hint outranks mention hint; mention copy only when rich composer is on.
|
||||
*/
|
||||
export function resolveComposerPlaceholderKey(opts: {
|
||||
richMentionsEnabled: boolean
|
||||
showContinueHint: boolean
|
||||
}): 'misc.typeMessage' | 'misc.typeAMessageWithMentions' | 'misc.typeAMessage' {
|
||||
if (opts.showContinueHint) return 'misc.typeMessage'
|
||||
if (opts.richMentionsEnabled) return 'misc.typeAMessageWithMentions'
|
||||
return 'misc.typeAMessage'
|
||||
}
|
||||
|
||||
@@ -565,6 +565,8 @@ export default {
|
||||
'scratchlist.sendToScratchlist': 'Send to scratchlist',
|
||||
'scratchlist.fueTitle': 'New: Scratchlist',
|
||||
'scratchlist.fueBody': 'Park notes & drafts here without sending. The Send button glows amber while you stash; click the icon (or Ctrl/Cmd+Shift+S) again to leave.',
|
||||
'richComposer.fueTitle': 'New: @mention another session',
|
||||
'richComposer.fueBody': 'Type @ to mention another session. It becomes a chip (hover for sidebar-style details). On send, the message includes that session\'s full ID so the agent can inspect_peer or ping_peer.',
|
||||
'scratchlist.addPlaceholder': 'Note, draft, or idea — Enter to add',
|
||||
'scratchlist.addAriaLabel': 'Add scratchlist entry',
|
||||
'scratchlist.add': 'Add',
|
||||
@@ -964,6 +966,7 @@ export default {
|
||||
'misc.releaseToLoadOlder': 'Release to load earlier messages',
|
||||
'misc.typeMessage': "Type 'continue' to resume...",
|
||||
'misc.typeAMessage': 'Type a message...',
|
||||
'misc.typeAMessageWithMentions': 'Type what you want the agent to do, or @mention another session for context or handoff',
|
||||
'misc.offline': 'offline',
|
||||
'misc.permissionRequired': 'permission required',
|
||||
'misc.online': 'online',
|
||||
|
||||
@@ -564,6 +564,8 @@ export default {
|
||||
'scratchlist.sendToScratchlist': '存入暂存清单',
|
||||
'scratchlist.fueTitle': '新功能:暂存清单',
|
||||
'scratchlist.fueBody': '在此暂存笔记和草稿,不会被发送。暂存模式下发送按钮会显示琥珀色;再次点击图标(或 Ctrl/Cmd+Shift+S)可退出。',
|
||||
'richComposer.fueTitle': '新功能:@ 提及其他会话',
|
||||
'richComposer.fueBody': '输入 @ 可提及另一个会话,会变成芯片(悬停可查看与侧边栏一致的详情)。发送后消息会包含该会话的完整 ID,供智能体使用 inspect_peer 或 ping_peer。',
|
||||
'scratchlist.addPlaceholder': '笔记、草稿或想法 — 回车键添加',
|
||||
'scratchlist.addAriaLabel': '添加草稿夹条目',
|
||||
'scratchlist.add': '添加',
|
||||
@@ -963,6 +965,7 @@ export default {
|
||||
'misc.releaseToLoadOlder': '松开加载',
|
||||
'misc.typeMessage': "输入 'continue' 继续...",
|
||||
'misc.typeAMessage': '输入消息...',
|
||||
'misc.typeAMessageWithMentions': '输入想让智能体做的事,或用 @ 提及另一个会话以提供上下文或移交',
|
||||
'misc.offline': 'offline',
|
||||
'misc.permissionRequired': '需要权限',
|
||||
'misc.online': 'online',
|
||||
|
||||
Reference in New Issue
Block a user