实现 Codex 风格上下文压缩时间线交互

This commit is contained in:
2026-08-15 08:43:33 +08:00
parent 953b0f491e
commit fd9b5b46a9
13 changed files with 2044 additions and 73 deletions

View File

@@ -0,0 +1,39 @@
import { Sparkles } from 'lucide-react';
import type { OpencodeCompactionTimelineEvent } from '@/types/opencode';
import { cn } from '@/lib/utils';
function getCompactionLabel(event: OpencodeCompactionTimelineEvent): string {
if (event.source === 'manual') {
return event.status === 'running' ? '正在压缩上下文' : '已压缩上下文';
}
return event.status === 'running' ? '正在优化对话' : '已优化对话';
}
export function ContextCompactionTimelineItem({
event,
announce,
}: {
event: OpencodeCompactionTimelineEvent;
announce: boolean;
}) {
const running = event.status === 'running';
const liveRegionProps = announce
? { role: 'status' as const, 'aria-live': 'polite' as const, 'aria-atomic': true }
: {};
return (
<div
className="flex items-center gap-2 py-1 text-xs text-muted-foreground"
data-testid="opencode-compaction-timeline-item"
data-compaction-id={event.id}
data-compaction-source={event.source}
data-compaction-status={event.status}
{...liveRegionProps}
>
<Sparkles className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden="true" />
<span className={cn(running && 'context-compaction-shimmer')}>
{getCompactionLabel(event)}
</span>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import {
} from './ChatCommandDialogs';
import { ChatCommandPalette } from './ChatCommandPalette';
import { ChatMessage } from './ChatMessage';
import { ContextCompactionTimelineItem } from './ContextCompactionTimelineItem';
import { ComposerAttachmentCard } from './ComposerAttachmentCard';
import { ExecutionGraphCard } from './ExecutionGraphCard';
import { GameAssetBrowser } from './GameAssetBrowser';
@@ -48,6 +49,7 @@ import {
} from './message-utils';
import { deriveTaskSteps, type TaskStep } from './task-visualization';
import {
buildSessionTranscriptTimeline,
projectAssistantTextWithNativeParts,
} from './session-transcript-view-model';
import {
@@ -821,7 +823,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const sessionTranscript = useOpencodeStore((state) => (
selectedSessionId ? state.sessionTranscriptBySessionId[selectedSessionId] : undefined
));
const compactingSessionIds = useOpencodeStore((state) => state.compactingSessionIds);
const sessionMessages = useOpencodeStore((state) => state.sessionMessages);
const streamingMessage = useOpencodeStore((state) => state.streamingMessage);
const streamingTools = useOpencodeStore((state) => state.streamingTools);
@@ -939,6 +940,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const messageElementsRef = useRef(new Map<string, HTMLDivElement>());
const undoOriginatingDraftRef = useRef('');
const transcriptShouldStickRef = useRef(true);
const seenCompactionIdsBySessionRef = useRef(new Map<string, Set<string>>());
const lastCompactionAnnouncementSessionRef = useRef<string | null>(null);
const composerAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const composerStopArmTimerRef = useRef<number | null>(null);
const userModelConfigSyncRef = useRef<Promise<void> | null>(null);
@@ -1081,6 +1084,19 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
selectedSessionRunning,
);
}, [selectedSessionRunning, sessionMessages, streamingMessage]);
const transcriptTimeline = useMemo(() => buildSessionTranscriptTimeline(
visibleTranscriptMessages,
sessionTranscript,
), [sessionTranscript, visibleTranscriptMessages]);
const transcriptCompactions = useMemo(() => transcriptTimeline.flatMap((item) => (
item.type === 'compaction' ? [item.event] : []
)), [transcriptTimeline]);
const compactionTimelineVersion = transcriptCompactions
.map((event) => `${event.id}:${event.status}`)
.join('|');
const [liveCompactionIds, setLiveCompactionIds] = useState<ReadonlySet<string>>(
() => new Set(),
);
const undoableUserMessage = findUndoableUserMessage(
visibleTranscriptMessages,
currentSession?.revert?.messageID,
@@ -1363,6 +1379,31 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
}
}, [selectedSessionId]);
useEffect(() => {
if (!selectedSessionId) {
lastCompactionAnnouncementSessionRef.current = null;
setLiveCompactionIds(new Set());
return;
}
const seen = seenCompactionIdsBySessionRef.current.get(selectedSessionId) ?? new Set<string>();
const sessionChanged = lastCompactionAnnouncementSessionRef.current !== selectedSessionId;
lastCompactionAnnouncementSessionRef.current = selectedSessionId;
const newRunningIds = sessionChanged ? [] : transcriptCompactions
.filter((event) => event.status === 'running' && !seen.has(event.id))
.map((event) => event.id);
for (const event of transcriptCompactions) seen.add(event.id);
seenCompactionIdsBySessionRef.current.set(selectedSessionId, seen);
if (sessionChanged) {
setLiveCompactionIds(new Set());
return;
}
if (newRunningIds.length === 0) return;
setLiveCompactionIds((current) => new Set([...current, ...newRunningIds]));
}, [selectedSessionId, transcriptCompactions]);
useEffect(() => () => {
if (composerStopArmTimerRef.current !== null) {
window.clearTimeout(composerStopArmTimerRef.current);
@@ -1413,7 +1454,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
setHasUnreadTranscript(false);
return;
}
if (visibleTranscriptMessages.length > 0) {
if (visibleTranscriptMessages.length > 0 || transcriptCompactions.length > 0) {
setHasUnreadTranscript(true);
}
}, [
@@ -1422,6 +1463,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
awaitingFirstResponse,
visiblePendingQuestions.length,
visibleTranscriptMessages,
compactionTimelineVersion,
transcriptCompactions.length,
]);
const handleTranscriptScroll = useCallback(() => {
@@ -2610,8 +2653,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
onScroll={handleTranscriptScroll}
>
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col pb-4 sm:pb-8">
{selectedSessionId && compactingSessionIds[selectedSessionId] ? <CompactionNotice assistantAvatarSrc={selectedProjectAgentAvatarSrc} assistantAvatarAlt={selectedProjectAgentAvatarAlt} /> : null}
{visibleTranscriptMessages.length === 0 ? (
{transcriptTimeline.length === 0 ? (
awaitingFirstResponse ? (
<div className="space-y-2">
<AssistantWaitingPlaceholder elapsedSeconds={responseWaitElapsedSeconds} assistantAvatarSrc={selectedProjectAgentAvatarSrc} assistantAvatarAlt={selectedProjectAgentAvatarAlt} />
@@ -2623,7 +2665,17 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
)
) : (
<div className="space-y-6 sm:space-y-8">
{visibleTranscriptMessages.map((message, index) => {
{transcriptTimeline.map((timelineItem) => {
if (timelineItem.type === 'compaction') {
return (
<ContextCompactionTimelineItem
key={timelineItem.id}
event={timelineItem.event}
announce={liveCompactionIds.has(timelineItem.event.id)}
/>
);
}
const { message, messageIndex: index } = timelineItem;
const startingExecutionRun = executionRunByStartIndex.get(index);
const messageExecutionRun = getExecutionRunForIndex(executionRuns, index);
const liveStreaming = Boolean(
@@ -3635,20 +3687,6 @@ function AssistantWaitingPlaceholder({ elapsedSeconds, assistantAvatarSrc, assis
);
}
function CompactionNotice({ assistantAvatarSrc, assistantAvatarAlt }: { assistantAvatarSrc?: string; assistantAvatarAlt?: string }) {
return (
<div className="mb-5 flex gap-3" data-testid="opencode-compaction-notice" aria-live="polite">
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md border border-foreground/15 bg-surface-subtle">
{assistantAvatarSrc ? <img src={assistantAvatarSrc} alt={assistantAvatarAlt} className="h-full w-full object-cover [image-rendering:pixelated]" /> : <Bot className="h-4 w-4" />}
</div>
<div className="flex items-center gap-2 rounded-2xl border border-border/70 bg-surface-subtle px-4 py-3 text-sm text-muted-foreground shadow-soft">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span>正在压缩上下文</span>
</div>
</div>
);
}
type PendingQuestionCardProps = {
request: OpencodeQuestionRequest;
onReply: (requestId: string, answers: string[][]) => Promise<void>;

View File

@@ -1,9 +1,11 @@
import type { RawMessage } from '@/types/chat';
import type {
OpencodeCompactionTimelineEvent,
OpencodeNativeToolState,
OpencodeSessionExecutionState,
OpencodeSessionTranscriptState,
} from '@/types/opencode';
import { getOrderedSessionCompactions } from '@/lib/opencode-session-state';
import {
extractThinkingSegments,
extractToolUse,
@@ -60,6 +62,19 @@ export interface SessionTranscriptViewModel {
nestedSubtasks: SessionTranscriptNestedSubtask[];
}
export type SessionTranscriptTimelineItem =
| {
type: 'message';
id: string;
message: RawMessage;
messageIndex: number;
}
| {
type: 'compaction';
id: string;
event: OpencodeCompactionTimelineEvent;
};
export interface BuildSessionTranscriptViewModelInput {
messages: RawMessage[];
nativeTranscript?: OpencodeSessionTranscriptState;
@@ -76,6 +91,64 @@ function stableMessageId(message: RawMessage, index: number): string {
return message.id?.trim() || `${message.role}-${index}`;
}
function getCompactionInsertionIndex(
event: OpencodeCompactionTimelineEvent,
messages: RawMessage[],
nativeTranscript: OpencodeSessionTranscriptState,
): number {
const anchoredPart = event.anchorPartID
? nativeTranscript.partsById[event.anchorPartID]
: undefined;
const anchorMessageID = event.anchorMessageID ?? anchoredPart?.messageID;
if (!anchorMessageID) return messages.length;
const visibleMessageIndex = messages.findIndex(
(message) => message.id?.trim() === anchorMessageID,
);
if (visibleMessageIndex >= 0) return visibleMessageIndex + 1;
const nativeAnchorIndex = nativeTranscript.messageOrder.indexOf(anchorMessageID);
if (nativeAnchorIndex < 0) return messages.length;
for (let index = 0; index < messages.length; index += 1) {
const messageID = messages[index]?.id?.trim();
if (!messageID) continue;
const nativeMessageIndex = nativeTranscript.messageOrder.indexOf(messageID);
if (nativeMessageIndex > nativeAnchorIndex) return index;
}
return messages.length;
}
export function buildSessionTranscriptTimeline(
messages: RawMessage[],
nativeTranscript: OpencodeSessionTranscriptState | undefined,
): SessionTranscriptTimelineItem[] {
const messageItems: SessionTranscriptTimelineItem[] = messages.map((message, messageIndex) => ({
type: 'message',
id: stableMessageId(message, messageIndex),
message,
messageIndex,
}));
if (!nativeTranscript) return messageItems;
const compactionsByInsertionIndex = new Map<number, OpencodeCompactionTimelineEvent[]>();
for (const event of getOrderedSessionCompactions(nativeTranscript)) {
const insertionIndex = getCompactionInsertionIndex(event, messages, nativeTranscript);
const events = compactionsByInsertionIndex.get(insertionIndex) ?? [];
events.push(event);
compactionsByInsertionIndex.set(insertionIndex, events);
}
const timeline: SessionTranscriptTimelineItem[] = [];
for (let index = 0; index <= messages.length; index += 1) {
for (const event of compactionsByInsertionIndex.get(index) ?? []) {
timeline.push({ type: 'compaction', id: `compaction:${event.id}`, event });
}
const messageItem = messageItems[index];
if (messageItem) timeline.push(messageItem);
}
return timeline;
}
function nativePartStatus(
part: OpencodeSessionTranscriptState['partsById'][string],
): SessionTranscriptTraceItem['status'] {