wip(web): agent preset UI flow — creator intro, custom group, subagent flash, chrome polish
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSettingsState } from './settings-store.ts'
|
||||
@@ -57,7 +57,7 @@ export function AgentPresetLabel({
|
||||
const text = option === undefined ? undefined : presetDisplayText(option, t)
|
||||
return (
|
||||
<span className={css.label} title={text?.description ?? t('headerHint')}>
|
||||
<IconThinkOutline16 className={css.icon} />
|
||||
<IconAgentPresetOutline16 className={css.icon} />
|
||||
{text?.name ?? preset}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -36,6 +36,59 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Introduce cue: the icon eases in on an overshoot-free expo curve, then the
|
||||
name's characters fade up on a stagger (delays set inline per character).
|
||||
All chars occupy their width from the start, so nothing reflows mid-run. */
|
||||
.introIcon {
|
||||
animation: seat-icon-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes seat-icon-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Wraps the staggered characters into one flex item, so the chip's gap
|
||||
applies around the name as a whole rather than between characters. */
|
||||
.introText {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.introChar {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
opacity: 0;
|
||||
animation: seat-char-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes seat-char-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.introIcon,
|
||||
.introChar {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the hero seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSeatState } from './seat-store.ts'
|
||||
@@ -32,8 +32,17 @@ export interface AgentPresetSeatInjected {
|
||||
load: () => Promise<void>
|
||||
/** Stage one preset for the next session. */
|
||||
select: (id: string) => Promise<void>
|
||||
/** Clear the one-shot introduce cue once the chip has played it. */
|
||||
introduced: () => void
|
||||
}
|
||||
|
||||
/* Introduce timeline: the icon eases in first; the name's characters start
|
||||
fading up once the icon has mostly landed, one every stagger tick, each
|
||||
taking the fade duration to settle. The cue clears after the last one. */
|
||||
const INTRO_TEXT_DELAY_MS = 300
|
||||
const INTRO_CHAR_STAGGER_MS = 60
|
||||
const INTRO_CHAR_FADE_MS = 400
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetSeatProps =
|
||||
PropsRuntime<'conversation.hero.agentPreset'>
|
||||
@@ -45,7 +54,7 @@ export type AgentPresetSeatProps =
|
||||
* @param props - composed slot props.
|
||||
* @returns the chip, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -53,12 +62,52 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (state.options.length === 0 || state.current === '') return null
|
||||
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
|
||||
const label = chosenText?.name ?? state.current
|
||||
const ready = state.options.length > 0 && state.current !== ''
|
||||
|
||||
// The introduce cue: the pick was staged from another screen (the settings
|
||||
// creator entry), so the chip announces it — the icon eases in and each
|
||||
// character of the name fades up on a stagger (CSS owns the motion; this
|
||||
// effect only arms it and acknowledges the cue once the run is over).
|
||||
const [introducing, setIntroducing] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!state.introduce || !ready) return
|
||||
const characters = Array.from(label)
|
||||
if (characters.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
introduced()
|
||||
return
|
||||
}
|
||||
setIntroducing(true)
|
||||
const done = window.setTimeout(() => {
|
||||
setIntroducing(false)
|
||||
introduced()
|
||||
}, INTRO_TEXT_DELAY_MS + characters.length * INTRO_CHAR_STAGGER_MS + INTRO_CHAR_FADE_MS)
|
||||
return () => { window.clearTimeout(done) }
|
||||
}, [state.introduce, ready, label, introduced])
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (!ready) return null
|
||||
|
||||
// One wrapper span: the chip is a flex row with a gap, so loose character
|
||||
// spans would each pick up the gap between them.
|
||||
const shownLabel = introducing
|
||||
? (
|
||||
<span className={css.introText}>
|
||||
{Array.from(label).map((character, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={css.introChar}
|
||||
style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * INTRO_CHAR_STAGGER_MS}ms` }}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
: label
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -95,8 +144,8 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
|
||||
disabled={state.busy}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<IconThinkOutline16 className={css.seatIcon} />
|
||||
{chosenText?.name ?? state.current}
|
||||
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
|
||||
{shownLabel}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -363,6 +363,7 @@
|
||||
create button vacated. Dashed like the Models page's add affordances: it
|
||||
reads as a place a preset will appear, not a command. */
|
||||
.creatorButton {
|
||||
box-sizing: border-box;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -372,17 +373,18 @@
|
||||
border: 1px dashed var(--dsw-alias-border-l3);
|
||||
border-radius: 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creatorButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.creatorButton:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,30 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
/* The guided alternative to copying: the self-referential preset can
|
||||
read this very composition and author a new one in conversation.
|
||||
Offered only where that preset is actually on the roster and a
|
||||
session can be landed; without a writable root the draft could
|
||||
never be discovered, so the reason rides the disabled button. */
|
||||
const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.creatorButton}
|
||||
disabled={!state.authorable}
|
||||
title={state.authorable ? undefined : t('duplicateUnavailable')}
|
||||
onClick={() => {
|
||||
props.startCreatorDraft?.()
|
||||
props.close()
|
||||
}}
|
||||
>
|
||||
{/* Same glyph as the Models page's add affordances. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('creatorDraft')}
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.title}>{t('nav')}</h2>
|
||||
@@ -180,147 +204,130 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
const group = state.rows
|
||||
.filter(row => row.trust === trust)
|
||||
.map(row => ({ row, text: presetDisplayText(row, t) }))
|
||||
if (group.length === 0) return null
|
||||
// The custom group is where a preset of one's own will appear, so it
|
||||
// stays on screen even while empty: heading plus the creator entry.
|
||||
const tail = trust === 'user' ? creatorButton : null
|
||||
if (group.length === 0 && tail === null) return null
|
||||
return (
|
||||
<section key={trust} className={css.group}>
|
||||
<h3 className={css.groupHead}>{heading}</h3>
|
||||
<ul className={css.cards}>
|
||||
{group.map(({ row, text }) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
? `${css.card} ${css.cardBroken}`
|
||||
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
|
||||
>
|
||||
{/* The card body IS the control: picking a preset is the
|
||||
{group.length === 0 ? null : (
|
||||
<ul className={css.cards}>
|
||||
{group.map(({ row, text }) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
? `${css.card} ${css.cardBroken}`
|
||||
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
|
||||
>
|
||||
{/* The card body IS the control: picking a preset is the
|
||||
common act, so it should not hide behind a small button.
|
||||
The action row sits outside it — nesting buttons is
|
||||
invalid, and these act on the card rather than select it.
|
||||
A broken preset cannot compose a session, so its body is
|
||||
disabled and the card says why instead of offering it. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault || row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
|
||||
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
: null}
|
||||
<span className={css.badge}>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault || row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
|
||||
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
: null}
|
||||
<span className={css.badge}>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
<code className={css.cardId}>{row.id}</code>
|
||||
</button>
|
||||
<div className={css.cardFoot}>
|
||||
{/* Shipped presets are the compositions a copy starts
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
<code className={css.cardId}>{row.id}</code>
|
||||
</button>
|
||||
<div className={css.cardFoot}>
|
||||
{/* Shipped presets are the compositions a copy starts
|
||||
from, so READING one is the point; a custom preset is
|
||||
edited in its files instead, which the location action
|
||||
leads to. A broken shipped preset has no readable
|
||||
composition to offer, so its viewer is withheld; a
|
||||
broken custom one keeps the location action — the
|
||||
files are where it gets fixed. */}
|
||||
{row.trust === 'system'
|
||||
? row.broken === undefined
|
||||
? (
|
||||
{row.trust === 'system'
|
||||
? row.broken === undefined
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${text.name}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${text.name}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
|
||||
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
|
||||
onClick={() => { void props.openLocation(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
<IconFolderOpenOutline16 />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
disabled={!state.authorable || row.broken !== undefined}
|
||||
data-tip={row.broken !== undefined
|
||||
? t('brokenNoCopy')
|
||||
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${text.name}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.iconButton} ${css.iconDanger}`}
|
||||
data-tip={t('delete')}
|
||||
aria-label={`${t('delete')}: ${text.name}`}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
: null}
|
||||
</div>
|
||||
{state.revealedPaths[row.id] === undefined
|
||||
? null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
|
||||
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
|
||||
onClick={() => { void props.openLocation(row.id) }}
|
||||
>
|
||||
<IconFolderOpenOutline16 />
|
||||
</button>
|
||||
<p className={css.revealedPath}>
|
||||
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
|
||||
<code>{state.revealedPaths[row.id]}</code>
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
disabled={!state.authorable || row.broken !== undefined}
|
||||
data-tip={row.broken !== undefined
|
||||
? t('brokenNoCopy')
|
||||
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${text.name}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.iconButton} ${css.iconDanger}`}
|
||||
data-tip={t('delete')}
|
||||
aria-label={`${t('delete')}: ${text.name}`}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
{state.revealedPaths[row.id] === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={css.revealedPath}>
|
||||
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
|
||||
<code>{state.revealedPaths[row.id]}</code>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{tail}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{/* The guided alternative to copying: the self-referential preset can
|
||||
read this very composition and author a new one in conversation.
|
||||
Offered only where that preset is actually on the roster and a
|
||||
session can be landed; without a writable root the draft could
|
||||
never be discovered, so the reason rides the disabled button. */}
|
||||
{props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.creatorButton}
|
||||
disabled={!state.authorable}
|
||||
title={state.authorable ? undefined : t('duplicateUnavailable')}
|
||||
onClick={() => {
|
||||
props.startCreatorDraft?.()
|
||||
props.close()
|
||||
}}
|
||||
>
|
||||
{/* Same glyph as the Models page's add affordances. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('creatorDraft')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<CopyDialog
|
||||
state={state}
|
||||
t={t}
|
||||
|
||||
@@ -114,6 +114,7 @@ export function apply(ctx: ClientContext): void {
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
introduced: () => { seat.introduced() },
|
||||
})
|
||||
|
||||
const labelInjected = (): AgentPresetLabelInjected => ({
|
||||
@@ -146,7 +147,9 @@ export function apply(ctx: ClientContext): void {
|
||||
// on: the chip's list-change applier composes the blank session the
|
||||
// workspace connect produces or reuses.
|
||||
creatorDraft = () => {
|
||||
seat.stage('cordis')
|
||||
// The introduce cue makes the chip announce the pick the user never
|
||||
// made on this screen — the stage happened back in settings.
|
||||
seat.stage('cordis', true)
|
||||
scope.workspaces.startSession()
|
||||
}
|
||||
const chip = scope.slots.register({
|
||||
|
||||
@@ -26,10 +26,16 @@ export interface AgentPresetSeatState {
|
||||
/** A rejected apply's message, cleared by the next attempt. */
|
||||
error: string | null
|
||||
busy: boolean
|
||||
/**
|
||||
* One-shot cue that the chip should introduce itself (the creator-draft
|
||||
* entry staged the pick from another screen, so the user never touched the
|
||||
* chip); the renderer clears it via `introduced()` once played.
|
||||
*/
|
||||
introduce: boolean
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSeatState = {
|
||||
options: [], current: '', error: null, busy: false,
|
||||
options: [], current: '', error: null, busy: false, introduce: false,
|
||||
}
|
||||
|
||||
/** One session's identity and whether it has started. */
|
||||
@@ -121,10 +127,18 @@ export class AgentPresetSeatController {
|
||||
* list-change applier, which fires when the started session becomes
|
||||
* current.
|
||||
* @param id - the preset to stage.
|
||||
* @param introduce - true when the stage came from another screen and the
|
||||
* chip should announce itself on the session it lands on.
|
||||
*/
|
||||
stage(id: string): void {
|
||||
stage(id: string, introduce = false): void {
|
||||
this.staged = id
|
||||
this.set({ current: id, error: null })
|
||||
this.set({ current: id, error: null, introduce })
|
||||
}
|
||||
|
||||
/** Acknowledge the introduction cue once the chip has played it. */
|
||||
introduced(): void {
|
||||
if (!this.store.getSnapshot().introduce) return
|
||||
this.set({ introduce: false })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user