fix(chat): keep the latest reply mounted through settlement and animate the timeline collapse
The streaming and settled versions of the trailing assistant reply were two components, so settling unmounted one and mounted the other and the entrance tween replayed over text the reader was already on. One LatestAssistantEntry now owns that row under a single key, and the history list excludes it. The CSS entrance keyframe that doubled the GSAP tween is gone and the tween matches the documented 160ms. The step timeline no longer remounts on settle: it is a button-controlled disclosure with a 180ms grid-rows transition, the reader's own toggle wins over the live default, and an in-flight request with no events yet shows a queued row instead of an empty shell. BUG-474 BUG-475 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ad9dba5c79
commit
aff9d19343
@@ -545,7 +545,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
|
||||
@keyframes app-loading-orbit { to { transform: rotate(360deg); } }
|
||||
@keyframes inline-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes message-enter { from { opacity: 0; transform: translateY(4px); } }
|
||||
@keyframes onboarding-card-enter { from { opacity: 0; transform: translateY(6px); } }
|
||||
@keyframes onboarding-caret { 50% { opacity: 0; } }
|
||||
@keyframes account-overlay-enter { from { opacity: 0; } }
|
||||
@@ -1034,7 +1033,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
min-height: 40vh;
|
||||
color: var(--color-ink-secondary);
|
||||
}
|
||||
.message { display: flex; animation: message-enter 160ms var(--ease-out) both; padding: var(--space-2) 0; }
|
||||
.message { display: flex; padding: var(--space-2) 0; }
|
||||
.agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
|
||||
.message-content { min-width: 0; max-width: min(80%, 680px); }
|
||||
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
@@ -1133,7 +1132,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.consultation-run-timeline {
|
||||
min-width: 0;
|
||||
}
|
||||
.consultation-run-timeline > summary,
|
||||
.consultation-run-timeline__summary,
|
||||
.consultation-run-timeline__details > summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20px;
|
||||
@@ -1144,12 +1143,40 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
list-style: none;
|
||||
color: var(--color-ink-tertiary);
|
||||
}
|
||||
.consultation-run-timeline > summary::-webkit-details-marker,
|
||||
.consultation-run-timeline__summary {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.consultation-run-timeline__summary:focus-visible {
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.consultation-run-timeline__details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.consultation-run-timeline__summary-label {
|
||||
min-width: 0;
|
||||
animation: agent-activity-status-in 120ms ease-out;
|
||||
}
|
||||
/* Open ↔ closed is a 180ms height transition on the same element, so settling
|
||||
never swaps the timeline out from under the reader. */
|
||||
.consultation-run-timeline__body-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 180ms var(--ease-out);
|
||||
}
|
||||
.consultation-run-timeline.is-open > .consultation-run-timeline__body-wrap {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.consultation-run-timeline__body-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.consultation-run-timeline__list {
|
||||
margin-top: var(--space-2);
|
||||
@@ -1184,7 +1211,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
color: var(--color-ink-tertiary);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
.consultation-run-timeline[open] > summary > .consultation-run-timeline__chevron,
|
||||
.consultation-run-timeline.is-open > .consultation-run-timeline__summary > .consultation-run-timeline__chevron,
|
||||
.consultation-run-timeline__details[open] > summary > .consultation-run-timeline__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@@ -1221,7 +1248,8 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.consultation-run-timeline__spinner,
|
||||
.consultation-run-timeline__summary-label,
|
||||
.consultation-run-timeline__body-wrap,
|
||||
.consultation-run-timeline__chevron {
|
||||
animation: none;
|
||||
transition: none;
|
||||
|
||||
@@ -87,7 +87,7 @@ export function ChatMessageRow({
|
||||
}, {
|
||||
autoAlpha: 1,
|
||||
clearProps: "opacity,transform,visibility",
|
||||
duration: 0.18,
|
||||
duration: 0.16,
|
||||
ease: "cubic-bezier(.22, 1, .36, 1)",
|
||||
y: 0,
|
||||
});
|
||||
|
||||
@@ -11,16 +11,21 @@ import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint";
|
||||
import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups";
|
||||
import type { ConsultationDomain } from "@/lib/consultation-domain-registry";
|
||||
import type { AgentActivityView, ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
|
||||
import { chatMessageViews, settledChatMessageViews, streamingChatMessageView } from "@/lib/chat-message-view";
|
||||
import {
|
||||
chatMessageViews,
|
||||
latestAssistantView,
|
||||
settledChatMessageViews,
|
||||
} from "@/lib/chat-message-view";
|
||||
import type { PublicThinkingSection } from "@/lib/consultation-thinking-plan";
|
||||
import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline";
|
||||
import {
|
||||
noteLatestEntryMount,
|
||||
noteSettledListRender,
|
||||
noteSettledRowRender,
|
||||
noteStreamingRowRender,
|
||||
noteUnsplitListRender,
|
||||
} from "@/lib/home-streaming-render-probe";
|
||||
import { memo, type MutableRefObject } from "react";
|
||||
import { memo, useEffect, type MutableRefObject } from "react";
|
||||
|
||||
export type ChatTranscriptActions = Readonly<{
|
||||
onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void;
|
||||
@@ -47,20 +52,7 @@ export type ChatTranscriptProps = Readonly<{
|
||||
actionsRef: MutableRefObject<ChatTranscriptActions>;
|
||||
}>;
|
||||
|
||||
const SettledMessageEntry = memo(function SettledMessageEntry({
|
||||
message,
|
||||
views,
|
||||
index,
|
||||
sessionId,
|
||||
sessionType,
|
||||
theme,
|
||||
messageFeedback,
|
||||
copiedMessageKey,
|
||||
loading,
|
||||
cancellationPending,
|
||||
productEntrypointsDisabled,
|
||||
actionsRef,
|
||||
}: Readonly<{
|
||||
type MessageEntryProps = Readonly<{
|
||||
message: ChatMessageView;
|
||||
views: readonly ChatMessageView[];
|
||||
index: number;
|
||||
@@ -73,8 +65,27 @@ const SettledMessageEntry = memo(function SettledMessageEntry({
|
||||
cancellationPending: boolean;
|
||||
productEntrypointsDisabled: boolean;
|
||||
actionsRef: MutableRefObject<ChatTranscriptActions>;
|
||||
}>) {
|
||||
noteSettledRowRender();
|
||||
}>;
|
||||
|
||||
/**
|
||||
* One transcript row. The same component renders history rows, the row that is
|
||||
* still streaming and the row that just settled: actions and follow-ups appear
|
||||
* once the view is settled, nothing remounts when it does.
|
||||
*/
|
||||
function MessageEntry({
|
||||
message,
|
||||
views,
|
||||
index,
|
||||
sessionId,
|
||||
sessionType,
|
||||
theme,
|
||||
messageFeedback,
|
||||
copiedMessageKey,
|
||||
loading,
|
||||
cancellationPending,
|
||||
productEntrypointsDisabled,
|
||||
actionsRef,
|
||||
}: MessageEntryProps) {
|
||||
const showActions = message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& Boolean(message.text);
|
||||
@@ -116,10 +127,21 @@ const SettledMessageEntry = memo(function SettledMessageEntry({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HistoryMessageEntry = memo(function HistoryMessageEntry(props: MessageEntryProps) {
|
||||
noteSettledRowRender();
|
||||
return <MessageEntry {...props} />;
|
||||
});
|
||||
|
||||
/**
|
||||
* History rows: every settled message except the trailing assistant reply,
|
||||
* which `LatestAssistantEntry` owns so it keeps one identity from the first
|
||||
* streamed token through settlement.
|
||||
*/
|
||||
export const SettledMessageList = memo(function SettledMessageList({
|
||||
messages,
|
||||
excludeLatestAssistant = false,
|
||||
sessionId,
|
||||
sessionType,
|
||||
theme,
|
||||
@@ -129,13 +151,16 @@ export const SettledMessageList = memo(function SettledMessageList({
|
||||
cancellationPending,
|
||||
productEntrypointsDisabled,
|
||||
actionsRef,
|
||||
}: Omit<ChatTranscriptProps, "streamingText" | "streamingActivity" | "streamingThinking" | "streamingSections" | "streamingTimeline">) {
|
||||
}: Omit<ChatTranscriptProps, "streamingText" | "streamingActivity" | "streamingThinking" | "streamingSections" | "streamingTimeline"> & {
|
||||
excludeLatestAssistant?: boolean;
|
||||
}) {
|
||||
noteSettledListRender();
|
||||
const views = settledChatMessageViews(messages);
|
||||
const history = excludeLatestAssistant && views.at(-1)?.role === "assistant" ? views.slice(0, -1) : views;
|
||||
return (
|
||||
<>
|
||||
{views.map((message, index) => (
|
||||
<SettledMessageEntry
|
||||
{history.map((message, index) => (
|
||||
<HistoryMessageEntry
|
||||
key={message.renderKey}
|
||||
message={message}
|
||||
views={views}
|
||||
@@ -155,15 +180,13 @@ export const SettledMessageList = memo(function SettledMessageList({
|
||||
);
|
||||
});
|
||||
|
||||
export const StreamingMessageEntry = memo(function StreamingMessageEntry({
|
||||
message,
|
||||
}: Readonly<{ message: ChatMessageView }>) {
|
||||
/** The trailing assistant reply, streaming or settled, under one React identity. */
|
||||
export const LatestAssistantEntry = memo(function LatestAssistantEntry(props: MessageEntryProps) {
|
||||
noteStreamingRowRender();
|
||||
return (
|
||||
<div className="message-entry">
|
||||
<ChatMessageRow message={message} />
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
noteLatestEntryMount();
|
||||
}, []);
|
||||
return <MessageEntry {...props} />;
|
||||
});
|
||||
|
||||
export const ChatTranscript = memo(function ChatTranscript({
|
||||
@@ -183,7 +206,7 @@ export const ChatTranscript = memo(function ChatTranscript({
|
||||
productEntrypointsDisabled,
|
||||
actionsRef,
|
||||
}: ChatTranscriptProps) {
|
||||
const streamingMessage = streamingChatMessageView(
|
||||
const latest = latestAssistantView(
|
||||
messages,
|
||||
loading,
|
||||
streamingText,
|
||||
@@ -197,6 +220,7 @@ export const ChatTranscript = memo(function ChatTranscript({
|
||||
<>
|
||||
<SettledMessageList
|
||||
messages={messages}
|
||||
excludeLatestAssistant
|
||||
loading={loading}
|
||||
sessionId={sessionId}
|
||||
sessionType={sessionType}
|
||||
@@ -207,7 +231,23 @@ export const ChatTranscript = memo(function ChatTranscript({
|
||||
productEntrypointsDisabled={productEntrypointsDisabled}
|
||||
actionsRef={actionsRef}
|
||||
/>
|
||||
{streamingMessage ? <StreamingMessageEntry message={streamingMessage} /> : null}
|
||||
{latest ? (
|
||||
<LatestAssistantEntry
|
||||
key={latest.view.renderKey}
|
||||
message={latest.view}
|
||||
views={latest.views}
|
||||
index={latest.views.length - 1}
|
||||
sessionId={sessionId}
|
||||
sessionType={sessionType}
|
||||
theme={theme}
|
||||
messageFeedback={messageFeedback}
|
||||
copiedMessageKey={copiedMessageKey}
|
||||
loading={loading}
|
||||
cancellationPending={cancellationPending}
|
||||
productEntrypointsDisabled={productEntrypointsDisabled}
|
||||
actionsRef={actionsRef}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -244,46 +284,22 @@ export function UnsplitChatTranscript({
|
||||
{views.map((message, index) => {
|
||||
noteSettledRowRender();
|
||||
if (message.state !== "settled") noteStreamingRowRender();
|
||||
const showActions = message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& Boolean(message.text);
|
||||
const feedbackKey = `${sessionId}:${message.renderKey}`;
|
||||
const latestRegeneratableKey = !loading && !cancellationPending
|
||||
? [...views].reverse().find((item) => (
|
||||
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
|
||||
))?.renderKey
|
||||
: undefined;
|
||||
const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : "";
|
||||
const followUps = showActions
|
||||
&& message.renderKey === latestRegeneratableKey
|
||||
&& sessionType === "consultation"
|
||||
&& previousQuestion
|
||||
? deriveConsultationFollowUps({
|
||||
question: previousQuestion,
|
||||
answer: message.text,
|
||||
theme,
|
||||
entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null,
|
||||
})
|
||||
: [];
|
||||
return (
|
||||
<div className="message-entry" key={message.renderKey}>
|
||||
<ChatMessageRow message={message} />
|
||||
{showActions && (
|
||||
<ChatMessageActions
|
||||
feedback={messageFeedback[feedbackKey]}
|
||||
copied={copiedMessageKey === feedbackKey}
|
||||
canRegenerate={message.renderKey === latestRegeneratableKey}
|
||||
onFeedback={(requested) => actionsRef.current.onFeedback(feedbackKey, requested)}
|
||||
onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)}
|
||||
onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)}
|
||||
/>
|
||||
)}
|
||||
<ConversationFollowUps
|
||||
questions={followUps}
|
||||
disabled={productEntrypointsDisabled}
|
||||
onSelect={(question) => actionsRef.current.onFollowUp(question)}
|
||||
/>
|
||||
</div>
|
||||
<MessageEntry
|
||||
key={message.renderKey}
|
||||
message={message}
|
||||
views={views}
|
||||
index={index}
|
||||
sessionId={sessionId}
|
||||
sessionType={sessionType}
|
||||
theme={theme}
|
||||
messageFeedback={messageFeedback}
|
||||
copiedMessageKey={copiedMessageKey}
|
||||
loading={loading}
|
||||
cancellationPending={cancellationPending}
|
||||
productEntrypointsDisabled={productEntrypointsDisabled}
|
||||
actionsRef={actionsRef}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { BookOpen, Check, ChevronDown, Layers, ListTodo, LoaderCircle, PenLine, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
@@ -18,6 +19,23 @@ const KIND_ICONS: Record<ConsultationTimelineKind, LucideIcon> = {
|
||||
write: PenLine,
|
||||
};
|
||||
|
||||
/** Shown while the request is in flight but no step has been reported yet. */
|
||||
export const QUEUED_TIMELINE_ROW: ConsultationTimelineRow = {
|
||||
id: "queued",
|
||||
kind: "method",
|
||||
status: "live",
|
||||
label: "正在处理…",
|
||||
};
|
||||
|
||||
export function timelineSummaryLabel(rows: readonly ConsultationTimelineRow[], live: boolean): string {
|
||||
return live ? "正在分析" : `已完成 ${rows.length} 步`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step timeline of one assistant reply. Open by default while live and
|
||||
* closed once settled; a reader's own toggle wins over both. The element keeps
|
||||
* its identity across settlement so the collapse is a transition, not a swap.
|
||||
*/
|
||||
export function ConsultationRunTimeline({
|
||||
rows,
|
||||
live = false,
|
||||
@@ -25,26 +43,43 @@ export function ConsultationRunTimeline({
|
||||
rows: readonly ConsultationTimelineRow[];
|
||||
live?: boolean;
|
||||
}>) {
|
||||
if (rows.length === 0) return null;
|
||||
const bodyId = useId();
|
||||
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
||||
const visibleRows = rows.length === 0 && live ? [QUEUED_TIMELINE_ROW] : rows;
|
||||
if (visibleRows.length === 0) return null;
|
||||
const open = userOpen ?? live;
|
||||
const summary = timelineSummaryLabel(rows, live);
|
||||
|
||||
return (
|
||||
<details
|
||||
key={live ? "live" : "settled"}
|
||||
className="consultation-run-timeline"
|
||||
open={live ? true : undefined}
|
||||
<section
|
||||
className={`consultation-run-timeline${open ? " is-open" : ""}${live ? " is-live" : ""}`}
|
||||
aria-label="分析步骤"
|
||||
>
|
||||
<summary className="consultation-run-timeline__summary">
|
||||
<span className="consultation-run-timeline__summary-label">
|
||||
{live ? "正在分析" : `已完成 ${rows.length} 步`}
|
||||
<button
|
||||
type="button"
|
||||
className="consultation-run-timeline__summary"
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
onClick={() => setUserOpen(!open)}
|
||||
>
|
||||
<span
|
||||
key={summary}
|
||||
className={`consultation-run-timeline__summary-label${live ? " agent-activity-status__text" : ""}`}
|
||||
>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown className="consultation-run-timeline__chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
<ol className="agent-thinking-timeline consultation-run-timeline__list" aria-label="步骤">
|
||||
{rows.map((row) => (
|
||||
<TimelineRow key={row.id} row={row} />
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</button>
|
||||
<div className="consultation-run-timeline__body-wrap" id={bodyId} inert={open ? undefined : true}>
|
||||
<div className="consultation-run-timeline__body-inner">
|
||||
<ol className="agent-thinking-timeline consultation-run-timeline__list" aria-label="步骤">
|
||||
{visibleRows.map((row) => (
|
||||
<TimelineRow key={row.id} row={row} />
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +103,13 @@ function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
|
||||
const label = (
|
||||
<span className="consultation-run-timeline__label">
|
||||
<KindIcon className="consultation-run-timeline__kind-icon" aria-hidden="true" />
|
||||
<span>{row.label}</span>
|
||||
<span
|
||||
key={row.label}
|
||||
className={row.status === "live" ? "agent-activity-status__text" : undefined}
|
||||
role={row.status === "live" ? "status" : undefined}
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -106,6 +106,43 @@ export function streamingChatMessageView(
|
||||
};
|
||||
}
|
||||
|
||||
export type LatestAssistantView = Readonly<{
|
||||
/** The trailing assistant reply: the live stream, or the last settled answer. */
|
||||
view: ChatMessageView;
|
||||
/** Every view in order, ending with `view`, for follow-up and regenerate lookups. */
|
||||
views: readonly ChatMessageView[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* The trailing assistant reply keeps one render key from its first streamed
|
||||
* token through settlement (`message-<index>` in both cases), so the same
|
||||
* component instance can carry it across the transition without remounting.
|
||||
*/
|
||||
export function latestAssistantView(
|
||||
messages: readonly ChatMessage[],
|
||||
loading: boolean,
|
||||
streamingText: string,
|
||||
activity?: AgentActivityView,
|
||||
thinkingText?: string,
|
||||
thinkingSections?: readonly PublicThinkingSection[],
|
||||
timeline?: readonly ConsultationTimelineRow[],
|
||||
): LatestAssistantView | undefined {
|
||||
const settled = settledChatMessageViews(messages);
|
||||
const streaming = streamingChatMessageView(
|
||||
messages,
|
||||
loading,
|
||||
streamingText,
|
||||
activity,
|
||||
thinkingText,
|
||||
thinkingSections,
|
||||
timeline,
|
||||
);
|
||||
if (streaming) return { view: streaming, views: [...settled, streaming] };
|
||||
const last = settled.at(-1);
|
||||
if (!last || last.role !== "assistant") return undefined;
|
||||
return { view: last, views: settled };
|
||||
}
|
||||
|
||||
export function chatMessageViews(
|
||||
messages: readonly ChatMessage[],
|
||||
loading: boolean,
|
||||
|
||||
@@ -3,6 +3,7 @@ export type HomeStreamingRenderProbeSnapshot = Readonly<{
|
||||
settledRowRenders: number;
|
||||
streamingRowRenders: number;
|
||||
unsplitListRenders: number;
|
||||
latestEntryMounts: number;
|
||||
}>;
|
||||
|
||||
const emptySnapshot = (): HomeStreamingRenderProbeSnapshot => ({
|
||||
@@ -10,6 +11,7 @@ const emptySnapshot = (): HomeStreamingRenderProbeSnapshot => ({
|
||||
settledRowRenders: 0,
|
||||
streamingRowRenders: 0,
|
||||
unsplitListRenders: 0,
|
||||
latestEntryMounts: 0,
|
||||
});
|
||||
|
||||
let enabled = false;
|
||||
@@ -48,3 +50,7 @@ export function noteStreamingRowRender() {
|
||||
export function noteUnsplitListRender() {
|
||||
if (enabled) snapshot = { ...snapshot, unsplitListRenders: snapshot.unsplitListRenders + 1 };
|
||||
}
|
||||
|
||||
export function noteLatestEntryMount() {
|
||||
if (enabled) snapshot = { ...snapshot, latestEntryMounts: snapshot.latestEntryMounts + 1 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user