实现 Codex 风格上下文压缩时间线交互
This commit is contained in:
39
src/pages/Chat/ContextCompactionTimelineItem.tsx
Normal file
39
src/pages/Chat/ContextCompactionTimelineItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
Reference in New Issue
Block a user