perf: optimize app startup and background lifecycles

This commit is contained in:
inman
2026-08-19 00:56:15 +08:00
parent 31332f6d76
commit 927e13349b
121 changed files with 4359 additions and 1400 deletions

View File

@@ -44,6 +44,7 @@ import {
uploadImageWorkspaceAsset,
} from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { subscribeHostEvent } from '@/lib/host-events';
import { transcribeWorksSpeech, WorksSquareApiError } from '@/lib/works-square';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
@@ -254,6 +255,10 @@ function activeQuote(messages: DesignMessage[]): DesignGenerationQuote | null {
type RenderedDesignMessage = DesignMessage & { streaming?: boolean };
const CANVAS_MESSAGE_WINDOW_SIZE = 150;
const CANVAS_MESSAGE_WINDOW_STEP = 120;
const CANVAS_BOTTOM_THRESHOLD_PX = 48;
type GenerationQuoteDraft = {
quoteId: string;
finalPrompt: string;
@@ -654,7 +659,7 @@ function ImageSourceAssetOption({
<Button
type="button"
size="sm"
className="h-10 rounded-xl px-3 text-xs font-semibold transition-transform active:scale-[0.96]"
className="h-10 rounded-xl px-3 text-xs font-semibold"
disabled={busy || !url}
onClick={onSelect}
>
@@ -928,6 +933,7 @@ export function ImageCanvas() {
const workspaceError = useImageWorkspaceStore((state) => state.error);
const load = useImageWorkspaceStore((state) => state.load);
const connectTaskStream = useImageWorkspaceStore((state) => state.connectTaskStream);
const disconnectTaskStream = useImageWorkspaceStore((state) => state.disconnectTaskStream);
const markTaskStreamNeedsReconciliation = useImageWorkspaceStore(
(state) => state.markTaskStreamNeedsReconciliation,
);
@@ -955,7 +961,11 @@ export function ImageCanvas() {
const [imageSourceError, setImageSourceError] = useState<string | null>(null);
const [uploadedImageSourceAsset, setUploadedImageSourceAsset] = useState<DesignAsset | null>(null);
const [voiceInputState, setVoiceInputState] = useState<'idle' | 'recording' | 'transcribing'>('idle');
const [conversationWindowStart, setConversationWindowStart] = useState<number | null>(null);
const conversationScrollRef = useRef<HTMLElement | null>(null);
const conversationEndRef = useRef<HTMLDivElement | null>(null);
const conversationWindowStartRef = useRef<number | null>(null);
const conversationShouldStickRef = useRef(true);
const promptRef = useRef<HTMLTextAreaElement | null>(null);
const referenceUploadInputRef = useRef<HTMLInputElement | null>(null);
const promptSelectionRef = useRef({ start: 0, end: 0 });
@@ -1047,6 +1057,13 @@ export function ImageCanvas() {
});
return messages;
}, [conversation, pendingTurn]);
const effectiveConversationWindowStart = conversationWindowStart === null
? Math.max(0, conversationMessages.length - CANVAS_MESSAGE_WINDOW_SIZE)
: Math.max(0, Math.min(conversationWindowStart, conversationMessages.length));
const windowedConversationMessages = useMemo(
() => conversationMessages.slice(effectiveConversationWindowStart),
[conversationMessages, effectiveConversationWindowStart],
);
const taskWorkspaceId = workspace?.workspaceId ?? null;
const taskConversationId = conversation?.conversationId ?? null;
const submitting = submittingConversationId === taskConversationId;
@@ -1074,15 +1091,27 @@ export function ImageCanvas() {
useEffect(() => {
if (!taskWorkspaceId || !taskConversationId) return;
connectTaskStream();
return markTaskStreamNeedsReconciliation;
return () => {
disconnectTaskStream();
markTaskStreamNeedsReconciliation();
};
}, [
connectTaskStream,
disconnectTaskStream,
markTaskStreamNeedsReconciliation,
taskConversationId,
taskWorkspaceId,
]);
useEffect(() => subscribeHostEvent('lifecycle:pause', () => {
disconnectTaskStream();
markTaskStreamNeedsReconciliation();
}), [disconnectTaskStream, markTaskStreamNeedsReconciliation]);
useEffect(() => {
conversationWindowStartRef.current = null;
conversationShouldStickRef.current = true;
setConversationWindowStart(null);
setPrompt('');
setReferenceAssets([]);
setReferenceMentionTrigger(null);
@@ -1121,10 +1150,38 @@ export function ImageCanvas() {
}, [consumePendingPrompt, pendingPrompt, taskConversationId]);
useEffect(() => {
if (!conversationShouldStickRef.current) return;
if (typeof conversationEndRef.current?.scrollIntoView === 'function') {
conversationEndRef.current.scrollIntoView({ block: 'end' });
}
}, [conversationMessages.length, pendingTurn?.assistantText]);
}, [conversationMessages.length, pendingTurn?.assistantText, effectiveConversationWindowStart]);
useEffect(() => {
if (!conversationShouldStickRef.current) return;
conversationWindowStartRef.current = null;
setConversationWindowStart(null);
}, [conversationMessages.length]);
const handleConversationScroll = useCallback(() => {
const element = conversationScrollRef.current;
if (!element) return;
const currentStart = conversationWindowStartRef.current
?? Math.max(0, conversationMessages.length - CANVAS_MESSAGE_WINDOW_SIZE);
if (element.scrollTop < 96 && currentStart > 0) {
const previousHeight = element.scrollHeight;
const nextStart = Math.max(0, currentStart - CANVAS_MESSAGE_WINDOW_STEP);
conversationWindowStartRef.current = nextStart;
setConversationWindowStart(nextStart);
window.requestAnimationFrame(() => {
const nextElement = conversationScrollRef.current;
if (!nextElement) return;
nextElement.scrollTop += nextElement.scrollHeight - previousHeight;
});
}
conversationShouldStickRef.current = element.scrollHeight
- element.scrollTop
- element.clientHeight <= CANVAS_BOTTOM_THRESHOLD_PX;
}, [conversationMessages.length]);
const repriceQuote = async (
requestedQuoteId: string,
@@ -1698,7 +1755,12 @@ export function ImageCanvas() {
<div data-testid="image-canvas-page" className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background font-sans text-foreground">
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:flex-row">
<section data-testid="image-workspace-conversation-pane" className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:h-full">
<main data-testid="image-workspace-conversation" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6">
<main
ref={conversationScrollRef}
data-testid="image-workspace-conversation"
onScroll={handleConversationScroll}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6"
>
<div className="mx-auto flex min-h-full w-full flex-col pb-4">
{conversationMessages.length === 0 ? (
<div
@@ -1716,7 +1778,19 @@ export function ImageCanvas() {
</div>
) : (
<div className="flex flex-col gap-7">
{conversationMessages.map((message) => {
{effectiveConversationWindowStart > 0 ? (
<button
type="button"
className="mx-auto block rounded-md border border-border/70 px-3 py-1.5 text-xs text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
onClick={() => {
conversationWindowStartRef.current = 0;
setConversationWindowStart(0);
}}
>
加载更早消息({effectiveConversationWindowStart})
</button>
) : null}
{windowedConversationMessages.map((message) => {
const assistant = message.role === 'assistant';
return (
<article
@@ -1832,7 +1906,7 @@ export function ImageCanvas() {
>
<div
data-testid="image-workspace-composer-surface"
className="chat-composer-surface relative flex min-w-0 flex-col gap-2 rounded-lg border border-foreground/15 bg-background p-3 text-foreground shadow-soft transition-[background-color,border-color,box-shadow] focus-within:ring-1 focus-within:ring-foreground/10"
className="chat-composer-surface relative flex min-w-0 flex-col gap-2 rounded-lg border border-foreground/15 bg-background p-3 text-foreground shadow-soft focus-within:ring-1 focus-within:ring-foreground/10"
>
{!generationAvailable ? (
<p className="mb-0 rounded-xl bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-700 dark:text-amber-300">
@@ -1929,7 +2003,7 @@ export function ImageCanvas() {
<Button
type="button"
variant="ghost"
className="motion-press h-8 rounded-full border-0 bg-transparent px-2 text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]"
className="motion-press h-8 rounded-full border-0 bg-transparent px-2 text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground"
aria-label="上传参考图"
aria-busy={referenceUploadBusy}
disabled={submitting || Boolean(imageSourceBusyKey) || referenceUploadBusy}
@@ -1970,7 +2044,7 @@ export function ImageCanvas() {
<Button
type="submit"
variant="ghost"
className="motion-press h-8 w-8 rounded-full border-0 bg-foreground p-0 text-background shadow-sm hover:bg-foreground/90 active:scale-[0.94]"
className="motion-press h-8 w-8 rounded-full border-0 bg-foreground p-0 text-background shadow-sm hover:bg-foreground/90"
aria-label="发送给设计 Agent"
disabled={submitting || !prompt.trim()}
>
@@ -2036,7 +2110,7 @@ export function ImageCanvas() {
size="icon"
variant="ghost"
aria-label={selectingImageReference ? '关闭参考图选择' : '关闭首帧选择'}
className="h-10 w-10 shrink-0 rounded-xl transition-transform active:scale-[0.96]"
className="h-10 w-10 shrink-0 rounded-xl"
disabled={Boolean(imageSourceBusyKey)}
>
<X className="h-4 w-4" />
@@ -2056,7 +2130,7 @@ export function ImageCanvas() {
htmlFor="design-image-source-upload"
aria-busy={imageSourceBusyKey === 'upload'}
className={cn(
'inline-flex h-10 cursor-pointer items-center justify-center rounded-xl bg-brand px-3 text-xs font-semibold text-primary-foreground shadow-sm transition-transform active:scale-[0.96]',
'inline-flex h-10 cursor-pointer items-center justify-center rounded-xl bg-brand px-3 text-xs font-semibold text-primary-foreground shadow-sm',
imageSourceBusyKey && 'pointer-events-none opacity-50',
)}
>