perf: optimize app startup and background lifecycles
This commit is contained in:
@@ -24,11 +24,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { DISCLOSURE_EXIT_MS } from '@/components/ui/disclosure';
|
||||
import {
|
||||
deriveAgentBrowserDiagnostics,
|
||||
closeAgentBrowser,
|
||||
getAgentBrowserState,
|
||||
navigateAgentBrowser,
|
||||
openAgentBrowser,
|
||||
presentAgentBrowser,
|
||||
readAgentBrowserEvents,
|
||||
setAgentBrowserDiagnostics,
|
||||
} from '@/lib/agent-browser';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -260,6 +262,10 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setOccluded(false);
|
||||
return undefined;
|
||||
}
|
||||
const updateOcclusion = () => setOccluded(hasModalOcclusion());
|
||||
updateOcclusion();
|
||||
const observer = new MutationObserver(updateOcclusion);
|
||||
@@ -270,7 +276,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
subtree: true,
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [isOpen]);
|
||||
|
||||
const applySnapshot = useCallback((next: AgentBrowserSnapshot) => {
|
||||
setSnapshot(next);
|
||||
@@ -336,31 +342,11 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
};
|
||||
}, [applySnapshot, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath || (!isOpen && !browserActive)) return undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const next = await getAgentBrowserState(projectPath);
|
||||
if (!cancelled) {
|
||||
applySnapshot(next);
|
||||
setError(null);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!cancelled) {
|
||||
setError(cause instanceof Error ? cause.message : '无法读取开发浏览器状态');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void refresh();
|
||||
const interval = window.setInterval(() => void refresh(), 2500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [applySnapshot, browserActive, isOpen, projectPath]);
|
||||
useEffect(() => () => {
|
||||
if (projectPath) {
|
||||
void closeAgentBrowser(projectPath).catch(() => undefined);
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
eventCursorRef.current = 0;
|
||||
@@ -368,43 +354,67 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
}, [snapshot?.browserId, snapshot?.projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !projectPath || snapshot?.state !== 'attached') {
|
||||
if (!projectPath || !browserActive) return undefined;
|
||||
void setAgentBrowserDiagnostics({ projectPath, enabled: diagnosticsOpen })
|
||||
.then((next) => applySnapshot(next))
|
||||
.catch(() => undefined);
|
||||
if (!diagnosticsOpen) {
|
||||
eventCursorRef.current = 0;
|
||||
setEvents([]);
|
||||
}
|
||||
return undefined;
|
||||
}, [applySnapshot, browserActive, diagnosticsOpen, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !diagnosticsOpen || !projectPath || snapshot?.state !== 'attached') {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
if (inFlight) return;
|
||||
inFlight = true;
|
||||
let retryDelay = 250;
|
||||
try {
|
||||
const page = await readAgentBrowserEvents({
|
||||
projectPath,
|
||||
after: eventCursorRef.current,
|
||||
methods: EVENT_METHODS,
|
||||
limit: 200,
|
||||
waitMs: 500,
|
||||
waitMs: 5_000,
|
||||
});
|
||||
if (cancelled) return;
|
||||
eventCursorRef.current = page.nextCursor;
|
||||
setEvents((current) => {
|
||||
const base = page.gap ? [] : current;
|
||||
return [...base, ...page.events].slice(-1000);
|
||||
return [...base, ...page.events].slice(-500);
|
||||
});
|
||||
// A real request is held by Main for up to waitMs. Test doubles and
|
||||
// transient failures can return immediately; back off those empty
|
||||
// responses so a fast server cannot turn the long-poll into a hot
|
||||
// microtask loop.
|
||||
retryDelay = page.events.length > 0 || page.hasMore ? 0 : 250;
|
||||
} catch {
|
||||
// State polling presents attachment failures without turning the panel noisy.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (!cancelled) {
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollTimer = null;
|
||||
void poll();
|
||||
}, retryDelay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
const interval = window.setInterval(() => void poll(), 750);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
if (pollTimer !== null) window.clearTimeout(pollTimer);
|
||||
};
|
||||
}, [isOpen, projectPath, snapshot?.state, snapshot?.generation]);
|
||||
}, [diagnosticsOpen, isOpen, projectPath, snapshot?.state, snapshot?.generation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || occluded || !projectPath || !browserActive) return undefined;
|
||||
@@ -509,6 +519,10 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
|
||||
const closePanel = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSnapshot(null);
|
||||
if (projectPath) {
|
||||
void closeAgentBrowser(projectPath).catch(() => undefined);
|
||||
}
|
||||
setPanelWidth(DEFAULT_PANEL_WIDTH);
|
||||
setDiagnosticsOpen(false);
|
||||
setDiagnosticsHeight(DEFAULT_DIAGNOSTICS_HEIGHT);
|
||||
@@ -521,7 +535,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
setPanelMounted(false);
|
||||
panelCloseTimerRef.current = null;
|
||||
}, DISCLOSURE_EXIT_MS);
|
||||
}, []);
|
||||
}, [projectPath]);
|
||||
|
||||
const handleResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
@@ -648,7 +662,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'no-drag relative flex h-10 w-10 items-center justify-center border-0 bg-transparent p-0 text-muted-foreground outline-none transition-[color,transform] duration-300 ease-spring hover:bg-transparent hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand/30 active:scale-[0.96]',
|
||||
'no-drag relative flex h-10 w-10 items-center justify-center border-0 bg-transparent p-0 text-muted-foreground outline-none transition-colors duration-100 hover:bg-transparent hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||
isOpen || browserActive
|
||||
? 'text-brand hover:text-brand'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
@@ -691,7 +705,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0 overflow-hidden transition-[width] [transition-duration:180ms] ease-out motion-reduce:transition-none"
|
||||
className="shrink-0 overflow-hidden"
|
||||
data-testid="agent-browser-panel-spacer"
|
||||
style={{
|
||||
width: isOpen ? `${panelWidth}px` : '0px',
|
||||
@@ -735,7 +749,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70 transition-[background-color,width] duration-300 ease-spring group-hover:w-0.5 group-hover:bg-brand/70',
|
||||
'absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70 transition-colors duration-100 group-hover:bg-brand/70',
|
||||
isResizing && 'w-0.5 bg-brand',
|
||||
)}
|
||||
/>
|
||||
@@ -758,7 +772,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground active:scale-[0.96]"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground"
|
||||
aria-label="后退"
|
||||
disabled={!browserActive || !snapshot?.canGoBack || busy}
|
||||
onClick={() => handleNavigate('back')}
|
||||
@@ -769,7 +783,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground active:scale-[0.96]"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground"
|
||||
aria-label="前进"
|
||||
disabled={!browserActive || !snapshot?.canGoForward || busy}
|
||||
onClick={() => handleNavigate('forward')}
|
||||
@@ -780,7 +794,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground active:scale-[0.96]"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground"
|
||||
aria-label="刷新网页"
|
||||
disabled={!browserActive || busy}
|
||||
onClick={() => handleNavigate('reload')}
|
||||
@@ -793,7 +807,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
value={address}
|
||||
onChange={(event) => setAddress(event.target.value)}
|
||||
aria-label="网页地址"
|
||||
className="h-7 min-w-0 flex-1 rounded-lg border border-border/90 bg-surface-input px-2.5 text-[11px] font-medium text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground focus:border-brand/40 focus:ring-2 focus:ring-brand/15"
|
||||
className="h-7 min-w-0 flex-1 rounded-lg border border-border/90 bg-surface-input px-2.5 text-[11px] font-medium text-foreground outline-none transition-colors duration-100 placeholder:text-muted-foreground focus:border-brand/40 focus:ring-2 focus:ring-brand/15"
|
||||
placeholder="输入 URL"
|
||||
spellCheck={false}
|
||||
/>
|
||||
@@ -827,8 +841,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background transition-[height,box-shadow] duration-200 ease-spring motion-reduce:transition-none',
|
||||
diagnosticsOpen && 'shadow-[0_-10px_24px_hsl(220_20%_16%_/_0.06)]',
|
||||
'relative flex shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background',
|
||||
isDiagnosticsResizing && 'select-none transition-none',
|
||||
)}
|
||||
style={{
|
||||
@@ -876,7 +889,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
aria-selected={activeTab === tab}
|
||||
aria-expanded={activeTab === tab && diagnosticsOpen}
|
||||
className={cn(
|
||||
'relative flex h-10 items-center gap-1.5 border-b-2 px-2.5 text-[11px] font-medium transition-[border-color,color,transform] duration-150 active:scale-[0.98]',
|
||||
'relative flex h-10 items-center gap-1.5 border-b-2 px-2.5 text-[11px] font-medium transition-colors duration-100',
|
||||
activeTab === tab
|
||||
? 'border-brand text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
@@ -895,7 +908,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground active:scale-[0.96]"
|
||||
className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground"
|
||||
aria-label="收起调试面板"
|
||||
title="收起调试面板"
|
||||
onClick={() => setDiagnosticsOpen(false)}
|
||||
@@ -907,7 +920,7 @@ export function AgentBrowserPanel({ projectId, projectPath }: AgentBrowserPanelP
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground active:scale-[0.96]"
|
||||
className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground"
|
||||
aria-label="展开调试面板"
|
||||
title="展开调试面板"
|
||||
onClick={() => setDiagnosticsOpen(true)}
|
||||
|
||||
@@ -866,7 +866,7 @@ function ImageLightbox({
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-foreground/65 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-foreground/65"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Image + buttons stacked */}
|
||||
@@ -939,8 +939,8 @@ function ToolCard({
|
||||
<Wrench className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<span className="font-mono text-xs">{name}</span>
|
||||
{expanded
|
||||
? <ChevronDown className="ml-auto h-3 w-3 transition-transform duration-200 ease-out" />
|
||||
: <ChevronRight className="ml-auto h-3 w-3 transition-transform duration-200 ease-out" />}
|
||||
? <ChevronDown className="ml-auto h-3 w-3" />
|
||||
: <ChevronRight className="ml-auto h-3 w-3" />}
|
||||
</button>
|
||||
<DisclosureContent open={expanded && input != null} innerClassName="px-3 pb-2">
|
||||
<pre className="overflow-x-auto text-xs text-muted-foreground">
|
||||
|
||||
@@ -71,7 +71,7 @@ export function ComposerAttachmentCard({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="h-10 w-10 shrink-0 border border-foreground/15 bg-white text-foreground transition-transform duration-150 ease-out active:scale-[0.985]"
|
||||
className="h-10 w-10 shrink-0 border border-foreground/15 bg-white text-foreground"
|
||||
aria-label={`\u79fb\u9664\u9644\u4ef6 ${attachment.displayName}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
@@ -95,7 +95,7 @@ export function ComposerAttachmentCard({
|
||||
type="button"
|
||||
data-testid="opencode-attachment-use-original"
|
||||
aria-pressed={attachment.selectedVariant === 'original'}
|
||||
className="min-h-10 rounded border border-foreground/15 px-2 py-1 text-[11px] font-medium transition-transform duration-150 ease-out active:scale-[0.985] aria-pressed:bg-surface-subtle"
|
||||
className="min-h-10 rounded border border-foreground/15 px-2 py-1 text-[11px] font-medium aria-pressed:bg-surface-subtle"
|
||||
onClick={() => onSelectVariant('original')}
|
||||
>
|
||||
{'\u4f7f\u7528\u539f\u56fe'}
|
||||
@@ -104,7 +104,7 @@ export function ComposerAttachmentCard({
|
||||
type="button"
|
||||
data-testid="opencode-attachment-use-compressed"
|
||||
aria-pressed={attachment.selectedVariant === 'compressed'}
|
||||
className="min-h-10 rounded border border-foreground/15 px-2 py-1 text-[11px] font-medium transition-transform duration-150 ease-out active:scale-[0.985] aria-pressed:bg-brand-soft"
|
||||
className="min-h-10 rounded border border-foreground/15 px-2 py-1 text-[11px] font-medium aria-pressed:bg-brand-soft"
|
||||
onClick={() => onSelectVariant('compressed')}
|
||||
>
|
||||
{'\u4f7f\u7528\u538b\u7f29\u56fe'}
|
||||
|
||||
@@ -156,7 +156,7 @@ function StepDetailCard({
|
||||
)}
|
||||
</div>
|
||||
{canExpand && (
|
||||
<span className="mt-0.5 shrink-0 text-muted-foreground transition-transform duration-200 ease-out">
|
||||
<span className="mt-0.5 shrink-0 text-muted-foreground">
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</span>
|
||||
)}
|
||||
@@ -283,7 +283,7 @@ export function ExecutionGraphCard({
|
||||
</span>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-background/70 text-muted-foreground">
|
||||
<ChevronRight className={cn(
|
||||
'h-3.5 w-3.5 transition-transform duration-200 ease-out',
|
||||
'h-3.5 w-3.5',
|
||||
expanded && 'rotate-90',
|
||||
)} />
|
||||
</span>
|
||||
|
||||
@@ -144,7 +144,7 @@ export function GameAssetBrowser(props: {
|
||||
<p className="text-pretty text-sm font-medium">素材卡片暂时无法加载,不影响继续对话</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 shrink-0 border border-foreground/15 bg-brand-soft font-semibold text-foreground transition-transform active:scale-[0.985]"
|
||||
className="h-10 shrink-0 border border-foreground/15 bg-brand-soft font-semibold text-foreground"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-4 w-4" />重新加载
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type ClipboardEvent as ReactClipboardEvent, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ArrowDown, Bot, ChevronDown, ChevronRight, CircleHelp, FileDiff, FileText, FolderOpen, Loader2, LockKeyhole, Mic, Plus, RotateCcw, Send, Settings as SettingsIcon, ShieldCheck, Square, Undo2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -95,8 +93,6 @@ import {
|
||||
stripGameAssetSelectionMarker,
|
||||
} from '@/lib/game-asset-selection-message';
|
||||
|
||||
gsap.registerPlugin(useGSAP);
|
||||
|
||||
function getSessionId(session: OpencodeSession): string {
|
||||
return String(session.id ?? session.sessionID ?? 'unknown-session');
|
||||
}
|
||||
@@ -113,6 +109,7 @@ function buildAutoSessionTitle(text: string): string {
|
||||
}
|
||||
|
||||
const EMPTY_TOOL_IDS: ReadonlySet<string> = new Set();
|
||||
const EMPTY_CHAT_TIMELINE_ENTRIES: ChatTimelineEntry[] = [];
|
||||
|
||||
function normalizeCommandError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
@@ -399,8 +396,8 @@ function isUnauthorizedWorksError(error: unknown): boolean {
|
||||
|| message.includes('invalid access token');
|
||||
}
|
||||
|
||||
const chatComposerButtonClassName = 'motion-press border-0 bg-transparent text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]';
|
||||
const chatComposerSendButtonClassName = 'motion-press border-0 bg-foreground text-background shadow-sm hover:bg-foreground/90 active:scale-[0.94]';
|
||||
const chatComposerButtonClassName = 'motion-press border-0 bg-transparent text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground';
|
||||
const chatComposerSendButtonClassName = 'motion-press border-0 bg-foreground text-background shadow-sm hover:bg-foreground/90';
|
||||
|
||||
function AgentResponseLoader() {
|
||||
const idPrefix = useId().replace(/:/gu, '');
|
||||
@@ -780,6 +777,8 @@ function getExecutionRunForIndex(
|
||||
}
|
||||
|
||||
const TRANSCRIPT_BOTTOM_THRESHOLD_PX = 48;
|
||||
const TRANSCRIPT_WINDOW_SIZE = 150;
|
||||
const TRANSCRIPT_WINDOW_STEP = 120;
|
||||
function isTranscriptNearBottom(element: HTMLDivElement): boolean {
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= TRANSCRIPT_BOTTOM_THRESHOLD_PX;
|
||||
}
|
||||
@@ -934,6 +933,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const [voiceWaveformLevels, setVoiceWaveformLevels] = useState<number[]>(() => [...VOICE_WAVEFORM_IDLE_SCALES]);
|
||||
const [voiceWaveformIsLive, setVoiceWaveformIsLive] = useState(false);
|
||||
const [hasUnreadTranscript, setHasUnreadTranscript] = useState(false);
|
||||
const [transcriptWindowStart, setTranscriptWindowStart] = useState<number | null>(null);
|
||||
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
|
||||
const [modelConfigPromptOpen, setModelConfigPromptOpen] = useState(false);
|
||||
const [quotaUpgradePromptKey, setQuotaUpgradePromptKey] = useState<string | null>(null);
|
||||
@@ -947,6 +947,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const messageElementsRef = useRef(new Map<string, HTMLDivElement>());
|
||||
const undoOriginatingDraftRef = useRef('');
|
||||
const transcriptShouldStickRef = useRef(true);
|
||||
const transcriptWindowStartRef = useRef<number | null>(null);
|
||||
const seenCompactionIdsBySessionRef = useRef(new Map<string, Set<string>>());
|
||||
const lastCompactionAnnouncementSessionRef = useRef<string | null>(null);
|
||||
const composerAttachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -1104,6 +1105,13 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const transcriptCompactions = useMemo(() => transcriptTimeline.flatMap((item) => (
|
||||
item.type === 'compaction' ? [item.event] : []
|
||||
)), [transcriptTimeline]);
|
||||
const effectiveTranscriptWindowStart = transcriptWindowStart === null
|
||||
? Math.max(0, transcriptTimeline.length - TRANSCRIPT_WINDOW_SIZE)
|
||||
: Math.max(0, Math.min(transcriptWindowStart, transcriptTimeline.length));
|
||||
const windowedTranscriptTimeline = useMemo(
|
||||
() => transcriptTimeline.slice(effectiveTranscriptWindowStart),
|
||||
[effectiveTranscriptWindowStart, transcriptTimeline],
|
||||
);
|
||||
const compactionTimelineVersion = transcriptCompactions
|
||||
.map((event) => `${event.id}:${event.status}`)
|
||||
.join('|');
|
||||
@@ -1127,21 +1135,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
clipboardAvailable:
|
||||
typeof navigator.clipboard?.writeText === 'function',
|
||||
};
|
||||
const timelineEntries = useMemo<ChatTimelineEntry[]>(() => (
|
||||
visibleTranscriptMessages.map((message, index) => {
|
||||
const label = timelineRoleLabel(message.role);
|
||||
const preview = extractText(message).trim().replace(/\s+/g, ' ');
|
||||
return {
|
||||
messageId: getMessageId(message, index),
|
||||
role: message.role,
|
||||
label,
|
||||
preview: preview.slice(0, 160) || `${label}消息`,
|
||||
...(typeof message.timestamp === 'number'
|
||||
? { timestamp: message.timestamp }
|
||||
: {}),
|
||||
};
|
||||
})
|
||||
), [visibleTranscriptMessages]);
|
||||
const latestAssistantMessageId = useMemo(() => {
|
||||
for (let index = visibleTranscriptMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = visibleTranscriptMessages[index];
|
||||
@@ -1366,6 +1359,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
useEffect(() => {
|
||||
setHasUnreadTranscript(false);
|
||||
transcriptShouldStickRef.current = true;
|
||||
transcriptWindowStartRef.current = null;
|
||||
setTranscriptWindowStart(null);
|
||||
setSelectedDiffPath(null);
|
||||
setComposerDragActive(false);
|
||||
setComposerStopArmed(true);
|
||||
@@ -1425,6 +1420,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
return () => window.clearInterval(timer);
|
||||
}, [voiceInputState, voiceRecordingStartedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transcriptShouldStickRef.current) return;
|
||||
transcriptWindowStartRef.current = null;
|
||||
setTranscriptWindowStart(null);
|
||||
}, [transcriptTimeline.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const transcriptElement = transcriptScrollRef.current;
|
||||
if (!transcriptElement) return;
|
||||
@@ -1449,12 +1450,25 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const handleTranscriptScroll = useCallback(() => {
|
||||
const transcriptElement = transcriptScrollRef.current;
|
||||
if (!transcriptElement) return;
|
||||
const currentStart = transcriptWindowStartRef.current
|
||||
?? Math.max(0, transcriptTimeline.length - TRANSCRIPT_WINDOW_SIZE);
|
||||
if (transcriptElement.scrollTop < 96 && currentStart > 0) {
|
||||
const previousHeight = transcriptElement.scrollHeight;
|
||||
const nextStart = Math.max(0, currentStart - TRANSCRIPT_WINDOW_STEP);
|
||||
transcriptWindowStartRef.current = nextStart;
|
||||
setTranscriptWindowStart(nextStart);
|
||||
window.requestAnimationFrame(() => {
|
||||
const nextElement = transcriptScrollRef.current;
|
||||
if (!nextElement) return;
|
||||
nextElement.scrollTop += nextElement.scrollHeight - previousHeight;
|
||||
});
|
||||
}
|
||||
const nearBottom = isTranscriptNearBottom(transcriptElement);
|
||||
transcriptShouldStickRef.current = nearBottom;
|
||||
if (nearBottom) {
|
||||
setHasUnreadTranscript(false);
|
||||
}
|
||||
}, []);
|
||||
}, [transcriptTimeline.length]);
|
||||
|
||||
const handleJumpToLatestMessage = useCallback(() => {
|
||||
const transcriptElement = transcriptScrollRef.current;
|
||||
@@ -2334,6 +2348,25 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
actions: commandActions,
|
||||
normalizeError: normalizeCommandError,
|
||||
});
|
||||
// The command timeline is a user-invoked diagnostic view. Do not rebuild
|
||||
// every preview string for a long session on every streamed token while the
|
||||
// dialog is closed.
|
||||
const timelineEntries = useMemo<ChatTimelineEntry[]>(() => {
|
||||
if (commands.dialog?.kind !== 'timeline') return EMPTY_CHAT_TIMELINE_ENTRIES;
|
||||
return visibleTranscriptMessages.map((message, index) => {
|
||||
const label = timelineRoleLabel(message.role);
|
||||
const preview = extractText(message).trim().replace(/\s+/g, ' ');
|
||||
return {
|
||||
messageId: getMessageId(message, index),
|
||||
role: message.role,
|
||||
label,
|
||||
preview: preview.slice(0, 160) || `${label}消息`,
|
||||
...(typeof message.timestamp === 'number'
|
||||
? { timestamp: message.timestamp }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}, [commands.dialog?.kind, visibleTranscriptMessages]);
|
||||
undoOriginatingDraftRef.current =
|
||||
commands.confirmation?.kind === 'undo'
|
||||
? commands.confirmation.originatingDraft
|
||||
@@ -2655,7 +2688,19 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6 sm:space-y-8">
|
||||
{transcriptTimeline.map((timelineItem) => {
|
||||
{effectiveTranscriptWindowStart > 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={() => {
|
||||
transcriptWindowStartRef.current = 0;
|
||||
setTranscriptWindowStart(0);
|
||||
}}
|
||||
>
|
||||
加载更早消息({effectiveTranscriptWindowStart})
|
||||
</button>
|
||||
) : null}
|
||||
{windowedTranscriptTimeline.map((timelineItem) => {
|
||||
if (timelineItem.type === 'compaction') {
|
||||
return (
|
||||
<ContextCompactionTimelineItem
|
||||
@@ -2902,7 +2947,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
<div
|
||||
data-testid="opencode-message-dropzone"
|
||||
className={cn(
|
||||
'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',
|
||||
'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',
|
||||
composerDragActive && 'bg-surface-subtle ring-2 ring-foreground/10',
|
||||
)}
|
||||
onDragEnter={handleComposerDragEnter}
|
||||
@@ -3288,7 +3333,7 @@ function PendingInteractionsDock({
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-amber-800/80">
|
||||
<span>{expanded ? '收起' : '展开'}</span>
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform duration-200 ease-out', expanded && 'rotate-180')} />
|
||||
<ChevronDown className={cn('h-3.5 w-3.5', expanded && 'rotate-180')} />
|
||||
</span>
|
||||
</button>
|
||||
<DisclosureContent
|
||||
@@ -3465,7 +3510,7 @@ function SessionChangesPanel({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 rounded-md px-2 text-xs text-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.985]"
|
||||
className="h-8 gap-1 rounded-md px-2 text-xs text-foreground hover:bg-surface-subtle hover:text-foreground"
|
||||
aria-label="撤销本次改动"
|
||||
disabled={!canRevert || loading}
|
||||
onClick={() => void onRevert()}
|
||||
@@ -3477,7 +3522,7 @@ function SessionChangesPanel({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 rounded-md px-2 text-xs text-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.985]"
|
||||
className="h-8 gap-1 rounded-md px-2 text-xs text-foreground hover:bg-surface-subtle hover:text-foreground"
|
||||
aria-label="恢复已撤销改动"
|
||||
disabled={!reverted || loading}
|
||||
onClick={() => void onUnrevert()}
|
||||
@@ -3499,12 +3544,12 @@ function SessionChangesPanel({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full gap-1 rounded-md text-xs text-muted-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.985]"
|
||||
className="h-8 w-full gap-1 rounded-md text-xs text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
|
||||
aria-label={`再显示 ${hiddenCount} 个文件`}
|
||||
onClick={() => setShowAllDiffs(true)}
|
||||
>
|
||||
<span>{`再显示 ${hiddenCount} 个文件`}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 transition-transform duration-200 ease-out" />
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -3514,12 +3559,12 @@ function SessionChangesPanel({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full gap-1 rounded-md text-xs text-muted-foreground hover:bg-surface-subtle hover:text-foreground active:scale-[0.985]"
|
||||
className="h-8 w-full gap-1 rounded-md text-xs text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
|
||||
aria-label="收起文件列表"
|
||||
onClick={() => setShowAllDiffs(false)}
|
||||
>
|
||||
<span>收起文件列表</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 rotate-90 transition-transform duration-200 ease-out" />
|
||||
<ChevronRight className="h-3.5 w-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user