实现 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

@@ -1,4 +1,6 @@
import type {
OpencodeCompactionSource,
OpencodeCompactionTimelineEvent,
OpencodeNativePartKind,
OpencodeNativeToolState,
OpencodeNormalizedSessionMessage,
@@ -13,6 +15,31 @@ export interface OpenCodeEventEnvelope {
payload: unknown;
}
export interface OpencodeCompactionMatch {
id?: string;
nativePartID?: string;
nativeEventID?: string;
runID?: string;
generation?: number;
}
export interface OpencodePendingCompactionInput extends OpencodeCompactionMatch {
source: OpencodeCompactionSource;
anchorMessageID?: string;
anchorPartID?: string;
startedAt?: number;
}
export interface OpencodeCompactionCompletion {
nativePartID?: string;
nativeEventID?: string;
anchorMessageID?: string;
anchorPartID?: string;
runID?: string;
generation?: number;
completedAt?: number;
}
const PART_TYPES = new Set<OpencodeNativePartKind>([
'text',
'reasoning',
@@ -79,6 +106,30 @@ function getPartId(value: unknown): string | undefined {
?? nonEmptyString(asRecord(record?.part)?.partID);
}
function getNativeEventId(value: unknown): string | undefined {
const record = asRecord(value);
const sequence = typeof record?.sequence === 'number' && Number.isFinite(record.sequence)
? String(record.sequence)
: nonEmptyString(record?.sequence);
return nonEmptyString(record?.eventID) ?? nonEmptyString(record?.eventId) ?? sequence;
}
function getRunId(value: unknown): string | undefined {
const record = asRecord(value);
return nonEmptyString(record?.runID)
?? nonEmptyString(record?.runId)
?? nonEmptyString(asRecord(record?.part)?.runID)
?? nonEmptyString(asRecord(record?.part)?.runId);
}
function getGeneration(value: unknown): number | undefined {
const record = asRecord(value);
const generation = record?.generation ?? asRecord(record?.part)?.generation;
return typeof generation === 'number' && Number.isFinite(generation)
? generation
: undefined;
}
function toMilliseconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
return Math.abs(value) < 1_000_000_000_000 ? value * 1_000 : value;
@@ -217,9 +268,197 @@ function createEmptyState(sessionID: string): OpencodeSessionTranscriptState {
messageOrder: [],
partsById: {},
partOrderByMessageId: {},
compactionsById: {},
compactionOrder: [],
};
}
function nextCompactionOrder(state: OpencodeSessionTranscriptState): number {
return state.compactionOrder.reduce((highest, id) => (
Math.max(highest, state.compactionsById[id]?.order ?? -1)
), -1) + 1;
}
function matchesCompaction(
event: OpencodeCompactionTimelineEvent,
match: OpencodeCompactionMatch,
): boolean {
const entries = Object.entries(match).filter(([, value]) => value !== undefined);
return entries.length > 0 && entries.every(([key, value]) => (
event[key as keyof OpencodeCompactionMatch] === value
));
}
function findCompactionId(
state: OpencodeSessionTranscriptState,
match: OpencodeCompactionMatch,
runningOnly = false,
): string | undefined {
return [...state.compactionOrder].reverse().find((id) => {
const event = state.compactionsById[id];
return event
&& (!runningOnly || event.status === 'running')
&& matchesCompaction(event, match);
});
}
function replaceCompaction(
state: OpencodeSessionTranscriptState,
event: OpencodeCompactionTimelineEvent,
): OpencodeSessionTranscriptState {
return {
...state,
compactionsById: { ...state.compactionsById, [event.id]: event },
compactionOrder: state.compactionOrder.includes(event.id)
? state.compactionOrder
: [...state.compactionOrder, event.id],
};
}
/** Add a renderer-owned running event before its native Part is available. */
export function createPendingCompaction(
state: OpencodeSessionTranscriptState,
input: OpencodePendingCompactionInput,
): OpencodeSessionTranscriptState {
const match: OpencodeCompactionMatch = {
...(input.id ? { id: input.id } : {}),
...(input.nativePartID ? { nativePartID: input.nativePartID } : {}),
...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}),
...(input.runID ? { runID: input.runID } : {}),
...(input.generation !== undefined ? { generation: input.generation } : {}),
};
const existingID = findCompactionId(state, match, true);
if (existingID) return state;
const order = nextCompactionOrder(state);
const id = input.id
?? `compaction:${state.sessionID}:${input.runID ?? input.generation ?? 'pending'}:${order}`;
if (state.compactionsById[id]) return state;
return replaceCompaction(state, {
id,
sessionID: state.sessionID,
source: input.source,
status: 'running',
order,
...(input.anchorMessageID ? { anchorMessageID: input.anchorMessageID } : {}),
...(input.anchorPartID ? { anchorPartID: input.anchorPartID } : {}),
...(input.nativePartID ? { nativePartID: input.nativePartID } : {}),
...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}),
...(input.runID ? { runID: input.runID } : {}),
...(input.generation !== undefined ? { generation: input.generation } : {}),
...(input.startedAt !== undefined ? { startedAt: input.startedAt } : {}),
});
}
/** Complete the precisely matched event, or the latest running event if no match is supplied. */
export function completeCompaction(
state: OpencodeSessionTranscriptState,
match: OpencodeCompactionMatch = {},
completion: OpencodeCompactionCompletion = {},
): OpencodeSessionTranscriptState {
const hasMatch = Object.values(match).some((value) => value !== undefined);
const id = hasMatch
? findCompactionId(state, match, true)
: [...state.compactionOrder].reverse().find((candidateID) => (
state.compactionsById[candidateID]?.status === 'running'
));
if (!id) return state;
const current = state.compactionsById[id];
return replaceCompaction(state, {
...current,
status: 'completed',
...(completion.nativePartID ? { nativePartID: completion.nativePartID } : {}),
...(completion.nativeEventID ? { nativeEventID: completion.nativeEventID } : {}),
...(completion.anchorMessageID ? { anchorMessageID: completion.anchorMessageID } : {}),
...(completion.anchorPartID ? { anchorPartID: completion.anchorPartID } : {}),
...(completion.runID ? { runID: completion.runID } : {}),
...(completion.generation !== undefined ? { generation: completion.generation } : {}),
...(completion.completedAt !== undefined ? { completedAt: completion.completedAt } : {}),
});
}
/** Remove a failed/cancelled event without relying on its mutable native correlation. */
export function removeCompaction(
state: OpencodeSessionTranscriptState,
match: OpencodeCompactionMatch,
): OpencodeSessionTranscriptState {
const id = findCompactionId(state, match);
if (!id) return state;
const compactionsById = { ...state.compactionsById };
delete compactionsById[id];
return {
...state,
compactionsById,
compactionOrder: state.compactionOrder.filter((candidateID) => candidateID !== id),
};
}
function compactionSource(part: OpencodeNormalizedSessionPart): OpencodeCompactionSource {
const raw = part.raw ?? {};
return raw.source === 'manual' || raw.auto === false ? 'manual' : 'automatic';
}
function upsertCompactionPart(
state: OpencodeSessionTranscriptState,
part: OpencodeNormalizedSessionPart,
nativeEventID?: string,
): OpencodeSessionTranscriptState {
if (part.type !== 'compaction') return state;
const raw = part.raw ?? {};
const runID = getRunId(raw);
const generation = getGeneration(raw);
const source = compactionSource(part);
const correlatedID = findCompactionId(state, { nativePartID: part.id });
let id = correlatedID;
if (!id && source === 'manual') {
id = [...state.compactionOrder].reverse().find((candidateID) => {
const candidate = state.compactionsById[candidateID];
return candidate?.source === 'manual'
&& candidate.status === 'running'
&& !candidate.nativePartID
&& (runID === undefined || candidate.runID === undefined || candidate.runID === runID)
&& (generation === undefined
|| candidate.generation === undefined
|| candidate.generation === generation);
});
}
if (!id) {
id = `compaction:${state.sessionID}:part:${part.id}`;
}
let next = state;
if (!correlatedID) {
for (const candidateID of state.compactionOrder) {
if (candidateID === id) continue;
if (state.compactionsById[candidateID]?.status !== 'running') continue;
next = completeCompaction(next, { id: candidateID });
}
}
const existing = next.compactionsById[id];
const endedAt = part.time?.end;
return replaceCompaction(next, {
id,
sessionID: state.sessionID,
source: existing?.source ?? source,
status: endedAt !== undefined ? 'completed' : existing?.status ?? 'running',
order: existing?.order ?? nextCompactionOrder(next),
anchorMessageID: part.messageID,
anchorPartID: part.id,
nativePartID: part.id,
...(nativeEventID ? { nativeEventID } : existing?.nativeEventID
? { nativeEventID: existing.nativeEventID }
: {}),
...(runID ? { runID } : existing?.runID ? { runID: existing.runID } : {}),
...(generation !== undefined ? { generation } : existing?.generation !== undefined
? { generation: existing.generation }
: {}),
...(part.time?.start !== undefined ? { startedAt: part.time.start } : existing?.startedAt !== undefined
? { startedAt: existing.startedAt }
: {}),
...(endedAt !== undefined ? { completedAt: endedAt } : existing?.completedAt !== undefined
? { completedAt: existing.completedAt }
: {}),
});
}
function ensureMessage(
state: OpencodeSessionTranscriptState,
input: unknown,
@@ -255,10 +494,11 @@ function ensureMessage(
function upsertPart(
state: OpencodeSessionTranscriptState,
part: OpencodeNormalizedSessionPart,
nativeEventID?: string,
): OpencodeSessionTranscriptState {
const message = state.messagesById[part.messageID];
const currentOrder = state.partOrderByMessageId[part.messageID] ?? [];
return {
const next = {
...state,
partsById: {
...state.partsById,
@@ -282,6 +522,7 @@ function upsertPart(
: [...currentOrder, part.id],
},
};
return upsertCompactionPart(next, part, nativeEventID);
}
function removePart(
@@ -293,7 +534,7 @@ function removePart(
const message = state.messagesById[part.messageID];
const nextParts = { ...state.partsById };
delete nextParts[partID];
return {
const next = {
...state,
partsById: nextParts,
messagesById: message
@@ -311,6 +552,9 @@ function removePart(
.filter((id) => id !== partID),
},
};
return part.type === 'compaction'
? removeCompaction(next, { nativePartID: partID })
: next;
}
function eventKey(event: OpenCodeEventEnvelope, sessionID?: string): string | undefined {
@@ -411,6 +655,7 @@ export function hydrateOpenCodeSession(
sessionID: string,
messages: unknown,
status: OpencodeSessionExecutionState = 'idle',
current?: OpencodeSessionTranscriptState,
): OpencodeSessionTranscriptState {
const state = createEmptyState(sessionID);
if (!Array.isArray(messages)) return { ...state, status };
@@ -426,6 +671,69 @@ export function hydrateOpenCodeSession(
if (part) next = upsertPart(next, part);
}
}
const snapshotOrder = [...next.compactionOrder];
snapshotOrder.forEach((id, index) => {
const snapshotEvent = next.compactionsById[id];
const existingID = snapshotEvent.nativePartID
? findCompactionId(current ?? state, { nativePartID: snapshotEvent.nativePartID })
: undefined;
const pendingID = !existingID && snapshotEvent.source === 'manual'
? [...(current?.compactionOrder ?? [])].reverse().find((candidateID) => {
const candidate = current?.compactionsById[candidateID];
return candidate?.source === 'manual'
&& candidate.status === 'running'
&& !candidate.nativePartID
&& (snapshotEvent.runID === undefined
|| candidate.runID === undefined
|| candidate.runID === snapshotEvent.runID)
&& (snapshotEvent.generation === undefined
|| candidate.generation === undefined
|| candidate.generation === snapshotEvent.generation);
})
: undefined;
const preservedID = existingID ?? pendingID;
const existing = preservedID ? current?.compactionsById[preservedID] : undefined;
const idToUse = preservedID ?? id;
const isLatestActive = status !== 'idle' && index === snapshotOrder.length - 1;
const completed = existing?.status === 'completed'
|| snapshotEvent.completedAt !== undefined
|| !isLatestActive;
if (idToUse !== id) {
const compactionsById = { ...next.compactionsById };
delete compactionsById[id];
next = {
...next,
compactionsById,
compactionOrder: next.compactionOrder.map((candidateID) => (
candidateID === id ? idToUse : candidateID
)),
};
}
next = replaceCompaction(next, {
...snapshotEvent,
...existing,
id: idToUse,
status: completed ? 'completed' : 'running',
order: index,
anchorMessageID: snapshotEvent.anchorMessageID,
anchorPartID: snapshotEvent.anchorPartID,
nativePartID: snapshotEvent.nativePartID,
...(snapshotEvent.nativeEventID ? { nativeEventID: snapshotEvent.nativeEventID } : {}),
...(snapshotEvent.runID ? { runID: snapshotEvent.runID } : {}),
...(snapshotEvent.generation !== undefined ? { generation: snapshotEvent.generation } : {}),
...(snapshotEvent.startedAt !== undefined ? { startedAt: snapshotEvent.startedAt } : {}),
...(snapshotEvent.completedAt !== undefined ? { completedAt: snapshotEvent.completedAt } : {}),
});
});
if (status !== 'idle' && current) {
for (const id of current.compactionOrder) {
const pending = current.compactionsById[id];
if (!pending || pending.status !== 'running' || pending.nativePartID) continue;
if (next.compactionsById[id]) continue;
next = replaceCompaction(next, { ...pending, order: nextCompactionOrder(next) });
}
}
return { ...next, status };
}
@@ -434,8 +742,9 @@ export function hydrateSessionSnapshot(
sessionID: string,
messages: unknown,
status: OpencodeSessionExecutionState = 'idle',
current?: OpencodeSessionTranscriptState,
): OpencodeSessionTranscriptState {
return hydrateOpenCodeSession(sessionID, messages, status);
return hydrateOpenCodeSession(sessionID, messages, status, current);
}
export function reduceOpenCodeEvent(
@@ -480,7 +789,7 @@ export function reduceOpenCodeEvent(
const messageID = part?.messageID || getMessageId(event.payload);
if (!part || !messageID) return state;
const withMessage = ensureMessage(state, record?.message ?? record?.part ?? record, sessionID, messageID);
return upsertPart(withMessage, { ...part, messageID });
return upsertPart(withMessage, { ...part, messageID }, getNativeEventId(event.payload));
}
if (event.type === 'session.status') {
@@ -491,14 +800,42 @@ export function reduceOpenCodeEvent(
status: type === 'busy' || type === 'retry' || type === 'idle' ? type : state.status,
};
}
if (event.type === 'session.idle' || event.type === 'session.compacted') {
return { ...state, status: 'idle' };
if (event.type === 'session.idle') {
return { ...completeCompaction(state), status: 'idle' };
}
if (event.type === 'session.compacted') {
const payload = asRecord(event.payload);
const match: OpencodeCompactionMatch = {
...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}),
...(getGeneration(event.payload) !== undefined
? { generation: getGeneration(event.payload) }
: {}),
};
const completedAt = toMilliseconds(
asRecord(payload?.time)?.completed
?? asRecord(payload?.time)?.end
?? payload?.completedAt
?? payload?.timestamp,
);
return completeCompaction(state, match, {
...(getNativeEventId(event.payload) ? { nativeEventID: getNativeEventId(event.payload) } : {}),
...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}),
...(getGeneration(event.payload) !== undefined
? { generation: getGeneration(event.payload) }
: {}),
...(completedAt !== undefined ? { completedAt } : {}),
});
}
if (event.type === 'session.error') {
const payload = asRecord(event.payload);
const error = payload?.error;
let next = state;
for (const id of state.compactionOrder) {
if (next.compactionsById[id]?.status !== 'running') continue;
next = removeCompaction(next, { id });
}
return {
...state,
...next,
status: 'idle',
lastError: stringifyDetail(error ?? payload?.message ?? payload?.errorMessage, 2_000),
};
@@ -519,3 +856,13 @@ export function getOrderedSessionParts(
export function getSessionPartCount(state: OpencodeSessionTranscriptState | undefined): number {
return state ? Object.keys(state.partsById).length : 0;
}
export function getOrderedSessionCompactions(
state: OpencodeSessionTranscriptState | undefined,
): OpencodeCompactionTimelineEvent[] {
if (!state) return [];
return state.compactionOrder
.map((id) => state.compactionsById[id])
.filter((event): event is OpencodeCompactionTimelineEvent => Boolean(event))
.sort((left, right) => left.order - right.order);
}

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'] {

View File

@@ -13,7 +13,10 @@ import {
removeStreamingPartFromMessage,
} from '@/lib/opencode-message';
import {
completeCompaction,
createPendingCompaction,
hydrateOpenCodeSession,
removeCompaction,
reduceOpenCodeEvent,
type OpenCodeEventEnvelope,
} from '@/lib/opencode-session-state';
@@ -162,7 +165,6 @@ interface OpencodeState {
sessionStatuses: OpencodeSessionStatusMap;
sessionTranscriptBySessionId: Record<string, OpencodeSessionTranscriptState>;
projectEventStreamState: 'closed' | 'connecting' | 'connected' | 'reconnecting';
compactingSessionIds: Record<string, boolean>;
sessionMessages: RawMessage[];
sessionMessagesBySessionId: Record<string, RawMessage[]>;
streamingMessage: RawMessage | null;
@@ -587,8 +589,12 @@ function getRuntimeStatusPatch(
status: OpencodeStatus,
): Pick<
OpencodeState,
'status' | 'runtimeGeneration' | 'commandsLoading' | 'commandsError'
> {
| 'status'
| 'runtimeGeneration'
| 'commandsLoading'
| 'commandsError'
| 'sessionTranscriptBySessionId'
> {
const identityChanged = runtimeIdentity(state.status) !== runtimeIdentity(status);
return {
status,
@@ -599,6 +605,9 @@ function getRuntimeStatusPatch(
),
commandsLoading: identityChanged ? false : state.commandsLoading,
commandsError: identityChanged ? null : state.commandsError,
sessionTranscriptBySessionId: identityChanged
? removeRunningCompactionsFromTranscripts(state.sessionTranscriptBySessionId)
: state.sessionTranscriptBySessionId,
};
}
@@ -1712,7 +1721,6 @@ function resetProjectScopedState(sessions: OpencodeSession[] = []) {
sessionStatuses: {},
sessionTranscriptBySessionId: {},
projectEventStreamState: 'closed' as const,
compactingSessionIds: {},
sessionMessages: [],
sessionMessagesBySessionId: {},
streamingMessage: null,
@@ -1877,6 +1885,85 @@ function getSessionTranscriptPatch(
};
}
function getOrCreateSessionTranscript(
state: OpencodeState,
sessionId: string,
): OpencodeSessionTranscriptState {
return state.sessionTranscriptBySessionId[sessionId]
?? hydrateOpenCodeSession(
sessionId,
[],
state.sessionStatuses[sessionId]?.type ?? 'idle',
);
}
function getCompactionEventIdentity(payload: Record<string, unknown>): string | undefined {
if (typeof payload.eventID === 'string' && payload.eventID.trim()) return payload.eventID;
if (typeof payload.eventId === 'string' && payload.eventId.trim()) return payload.eventId;
if (typeof payload.sequence === 'string' && payload.sequence.trim()) return payload.sequence;
return typeof payload.sequence === 'number' && Number.isFinite(payload.sequence)
? String(payload.sequence)
: undefined;
}
function withCompactionRunIdentity(
payload: Record<string, unknown>,
runToken: number,
generation: number,
source: 'manual' | 'automatic',
): Record<string, unknown> {
const part = payload.part;
if (!part || typeof part !== 'object' || Array.isArray(part)) return payload;
const partRecord = part as Record<string, unknown>;
if (partRecord.type !== 'compaction') return payload;
return {
...payload,
part: {
...partRecord,
runID: String(runToken),
generation,
...(source === 'manual' ? { auto: false } : {}),
},
};
}
function removeRunningCompactionsForRun(
transcript: OpencodeSessionTranscriptState,
runToken: number,
generation?: number,
): OpencodeSessionTranscriptState {
const runID = String(runToken);
let next = transcript;
for (const id of transcript.compactionOrder) {
const event = next.compactionsById[id];
if (
event?.status === 'running'
&& event.runID === runID
&& (generation === undefined || event.generation === generation)
) {
next = removeCompaction(next, { id });
}
}
return next;
}
function removeRunningCompactionsFromTranscripts(
transcripts: Record<string, OpencodeSessionTranscriptState>,
): Record<string, OpencodeSessionTranscriptState> {
let changed = false;
const next: Record<string, OpencodeSessionTranscriptState> = {};
for (const [sessionId, transcript] of Object.entries(transcripts)) {
let cleaned = transcript;
for (const id of transcript.compactionOrder) {
if (cleaned.compactionsById[id]?.status !== 'running') continue;
cleaned = removeCompaction(cleaned, { id });
}
changed ||= cleaned !== transcript;
next[sessionId] = cleaned;
}
return changed ? next : transcripts;
}
function getSessionStreamingEventPatch(
state: OpencodeState,
sessionId: string,
@@ -1907,19 +1994,9 @@ function getSessionStreamingEventPatch(
const streamingTools = nextToolStatus
? mergeStreamingToolStatuses(currentTools, nextToolStatus)
: currentTools;
const part = payload.part;
const compacting = Boolean(
part
&& typeof part === 'object'
&& !Array.isArray(part)
&& (part as Record<string, unknown>).type === 'compaction',
);
return {
...transcriptPatch,
...getSessionStreamingPatch(state, sessionId, streamingMessage, streamingTools),
compactingSessionIds: compacting
? { ...state.compactingSessionIds, [sessionId]: true }
: state.compactingSessionIds,
};
}
@@ -2009,7 +2086,12 @@ function getHydratedSessionTranscriptPatch(
return {
sessionTranscriptBySessionId: {
...state.sessionTranscriptBySessionId,
[sessionId]: hydrateOpenCodeSession(sessionId, rawMessages, status),
[sessionId]: hydrateOpenCodeSession(
sessionId,
rawMessages,
status,
state.sessionTranscriptBySessionId[sessionId],
),
},
};
}
@@ -2105,19 +2187,18 @@ function applyProjectEventToStore(
sessionStatuses: { ...state.sessionStatuses, [sessionId]: status },
};
}
if (type === 'session.idle' || type === 'session.compacted') {
if (type === 'session.idle') {
return {
...transcriptPatch,
sessionStatuses: { ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS },
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
...(active ? {} : clearSessionStreamingPatch(state, sessionId)),
};
}
if (type === 'session.compacted') return transcriptPatch;
if (type === 'session.error') {
return {
...transcriptPatch,
sessionStatuses: { ...state.sessionStatuses, [sessionId]: IDLE_SESSION_STATUS },
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
...(active ? {} : clearSessionStreamingPatch(state, sessionId)),
};
}
@@ -2328,7 +2409,6 @@ function finishSessionRunSuccessfully(
...statuses,
[sessionId]: IDLE_SESSION_STATUS,
},
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
@@ -2411,6 +2491,7 @@ async function runSessionSubmission(
? setMessageDeliveryStatus(submission.optimisticUserMessage, 'sending')
: undefined;
const baseline = createSessionRunBaseline(getSessionMessagesForState(get(), sessionId));
const runGeneration = get().runtimeGeneration;
const runToken = beginSessionRun(sessionId);
const promptId = submission.promptId;
closeSessionEventSource(sessionId);
@@ -2435,6 +2516,15 @@ async function runSessionSubmission(
runId: runToken,
promptId,
});
const transcript = submission.kind === 'compact'
? createPendingCompaction(getOrCreateSessionTranscript(state, sessionId), {
id: promptId,
source: 'manual',
runID: String(runToken),
generation: runGeneration,
startedAt: Date.now(),
})
: undefined;
return {
...getSessionMessagePatch(stateForMessagePatch, sessionId, sessionMessages),
...clearSessionStreamingPatch(state, sessionId),
@@ -2447,6 +2537,14 @@ async function runSessionSubmission(
...state.sessionStatuses,
[sessionId]: { type: 'busy' },
},
...(transcript
? {
sessionTranscriptBySessionId: {
...state.sessionTranscriptBySessionId,
[sessionId]: transcript,
},
}
: {}),
};
});
@@ -2497,19 +2595,35 @@ async function runSessionSubmission(
...state.sessionStatuses,
[sessionId]: IDLE_SESSION_STATUS,
},
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
}));
});
registerSessionRunEventListener(sessionId, source, 'session.compacted', (event) => {
const payload = JSON.parse((event as MessageEvent<string>).data) as Record<string, unknown>;
if (!isSessionRunCurrent(sessionId, runToken)) return;
if (get().runtimeGeneration !== runGeneration) return;
if (getOpencodeEventSessionId(payload) !== sessionId) return;
flushSessionRunStreamBatch(set, sessionId, runToken);
set((state) => ({
...getSessionTranscriptPatch(state, 'session.compacted', payload),
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
}));
set((state) => {
const transcript = completeCompaction(
getOrCreateSessionTranscript(state, sessionId),
{ runID: String(runToken), generation: runGeneration },
{
nativeEventID: getCompactionEventIdentity(payload),
runID: String(runToken),
generation: runGeneration,
completedAt: Date.now(),
},
);
return transcript === state.sessionTranscriptBySessionId[sessionId]
? {}
: {
sessionTranscriptBySessionId: {
...state.sessionTranscriptBySessionId,
[sessionId]: transcript,
},
};
});
});
registerSessionRunEventListener(sessionId, source, 'session.error', (event) => {
@@ -2556,7 +2670,14 @@ async function runSessionSubmission(
...state.sessionStatuses,
[sessionId]: IDLE_SESSION_STATUS,
},
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
sessionTranscriptBySessionId: {
...state.sessionTranscriptBySessionId,
[sessionId]: removeRunningCompactionsForRun(
getOrCreateSessionTranscript(state, sessionId),
runToken,
runGeneration,
),
},
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
@@ -2590,7 +2711,14 @@ async function runSessionSubmission(
...state.sessionStatuses,
[sessionId]: IDLE_SESSION_STATUS,
},
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
sessionTranscriptBySessionId: {
...state.sessionTranscriptBySessionId,
[sessionId]: removeRunningCompactionsForRun(
getOrCreateSessionTranscript(state, sessionId),
runToken,
runGeneration,
),
},
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
@@ -2657,8 +2785,16 @@ async function runSessionSubmission(
});
registerSessionRunEventListener(sessionId, source, 'message.part.updated', (event) => {
const payload = JSON.parse((event as MessageEvent<string>).data) as Record<string, unknown>;
const rawPayload = JSON.parse((event as MessageEvent<string>).data) as Record<string, unknown>;
if (!isSessionRunCurrent(sessionId, runToken)) return;
const payload = get().runtimeGeneration === runGeneration
? withCompactionRunIdentity(
rawPayload,
runToken,
runGeneration,
submission.kind === 'compact' ? 'manual' : 'automatic',
)
: rawPayload;
if (getOpencodeEventSessionId(payload) !== sessionId) return;
queueSessionRunStreamEvent(set, sessionId, runToken, 'message.part.updated', payload);
});
@@ -2744,8 +2880,13 @@ async function runSessionSubmission(
const now = Date.now();
const runListenerOwnsStream = hasSessionRunEventListener(sessionId);
const observedRunState = getSessionRunStateForSession(get(), sessionId);
const eventStatus = runListenerOwnsStream
? get().sessionStatuses[sessionId]
? observedRunState.runId === runToken
&& observedRunState.phase === 'idle'
&& observedRunState.terminalReason === 'completed'
? IDLE_SESSION_STATUS
: get().sessionStatuses[sessionId]
: undefined;
const shouldPollStatus = now >= nextStatusFallbackPollAt
|| eventStatus?.type === 'idle';
@@ -2797,9 +2938,13 @@ async function runSessionSubmission(
// A missing HTTP status is common while the runtime is transitioning.
// Prefer a newly observed assistant response over the optimistic/event
// busy marker, otherwise a completed run can remain busy forever.
const polledStatus = shouldPollStatus
? httpStatus ?? inferredStatus
: eventStatus ?? status ?? inferredStatus;
const polledStatus = submission.kind === 'compact'
? shouldPollStatus
? httpStatus ?? eventStatus ?? status
: eventStatus ?? status
: shouldPollStatus
? httpStatus ?? inferredStatus
: eventStatus ?? status ?? inferredStatus;
status = assistantError || assistantAborted ? IDLE_SESSION_STATUS : polledStatus;
const currentMessages = getSessionMessagesForState(get(), sessionId);
messages = assistantAborted || status.type === 'idle'
@@ -2848,6 +2993,14 @@ async function runSessionSubmission(
...statuses,
[sessionId]: IDLE_SESSION_STATUS,
},
sessionTranscriptBySessionId: {
...state.sessionTranscriptBySessionId,
[sessionId]: removeRunningCompactionsForRun(
getOrCreateSessionTranscript(state, sessionId),
runToken,
runGeneration,
),
},
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
@@ -2945,7 +3098,14 @@ async function runSessionSubmission(
...state.sessionStatuses,
[sessionId]: IDLE_SESSION_STATUS,
},
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
sessionTranscriptBySessionId: {
...state.sessionTranscriptBySessionId,
[sessionId]: removeRunningCompactionsForRun(
getOrCreateSessionTranscript(state, sessionId),
runToken,
runGeneration,
),
},
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: false,
@@ -2987,7 +3147,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
sessionRunStates: {},
pendingQuestions: [],
pendingPermissions: [],
compactingSessionIds: {},
sessionTodos: [],
sessionTodosBySessionId: {},
sessionTodosLoading: false,
@@ -3397,7 +3556,6 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
sessionStatuses: Object.fromEntries(
Object.entries(state.sessionStatuses).filter(([key]) => key !== sessionId),
),
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
selectedSessionId: deletingSelected ? null : selectedSessionId,
sessionMessages: deletingSelected ? [] : state.sessionMessages,
sessionMessagesBySessionId: withoutKey(state.sessionMessagesBySessionId, sessionId),
@@ -3437,6 +3595,7 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
const abortedRunId = getSessionRunStateForSession(get(), sessionId).runId
?? activeSessionRunTokens.get(sessionId)
?? null;
const abortedRunGeneration = get().runtimeGeneration;
invalidateSessionRun(sessionId);
closeSessionEventSource(sessionId);
set((state) => {
@@ -3456,7 +3615,18 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
...state.sessionStatuses,
[sessionId]: IDLE_SESSION_STATUS,
},
compactingSessionIds: withoutKey(state.compactingSessionIds, sessionId),
...(abortedRunId === null
? {}
: {
sessionTranscriptBySessionId: {
...state.sessionTranscriptBySessionId,
[sessionId]: removeRunningCompactionsForRun(
getOrCreateSessionTranscript(state, sessionId),
abortedRunId,
abortedRunGeneration,
),
},
}),
sendingSessionIds,
sendingSessionId: getSendingSessionIdForSelection(nextState),
loading: true,
@@ -4075,7 +4245,7 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
try {
return await runSessionSubmission(set, get, sessionId, {
kind: 'compact',
completion: 'post-success',
completion: 'observed',
promptId: `compact-${sessionId}-${Date.now()}`,
post: () => hostApiFetch<OpencodeSessionMessageActionResponse>(
`/api/opencode/sessions/${encodeURIComponent(sessionId)}/summarize`,

View File

@@ -737,6 +737,31 @@ html[data-ui-language='ru'] {
will-change: opacity, transform;
}
@keyframes context-compaction-shimmer {
from {
background-position: 100% 50%;
}
to {
background-position: -100% 50%;
}
}
.context-compaction-shimmer {
color: transparent;
background-image: linear-gradient(
100deg,
hsl(var(--muted-foreground) / 0.58) 25%,
hsl(var(--foreground) / 0.78) 50%,
hsl(var(--muted-foreground) / 0.58) 75%
);
background-position: 100% 50%;
background-size: 200% 100%;
background-clip: text;
animation: context-compaction-shimmer 2.4s linear infinite;
-webkit-background-clip: text;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -770,4 +795,10 @@ html[data-ui-language='ru'] {
transform: scaleY(1);
}
.context-compaction-shimmer {
color: hsl(var(--muted-foreground));
background-image: none;
animation: none;
}
}

View File

@@ -293,6 +293,30 @@ export interface OpencodeNormalizedSessionMessage {
partIDs: string[];
}
export type OpencodeCompactionSource = 'manual' | 'automatic';
export type OpencodeCompactionStatus = 'running' | 'completed';
/**
* A renderer-owned compaction timeline item. `id` is the immutable UI
* identity; native Part/event ids are correlation keys and may arrive later.
*/
export interface OpencodeCompactionTimelineEvent {
id: string;
sessionID: string;
source: OpencodeCompactionSource;
status: OpencodeCompactionStatus;
order: number;
anchorMessageID?: string;
anchorPartID?: string;
nativePartID?: string;
nativeEventID?: string;
runID?: string;
generation?: number;
startedAt?: number;
completedAt?: number;
}
export interface OpencodeSessionTranscriptState {
sessionID: string;
status: OpencodeSessionExecutionState;
@@ -300,6 +324,8 @@ export interface OpencodeSessionTranscriptState {
messageOrder: string[];
partsById: Record<string, OpencodeNormalizedSessionPart>;
partOrderByMessageId: Record<string, string[]>;
compactionsById: Record<string, OpencodeCompactionTimelineEvent>;
compactionOrder: string[];
lastError?: string;
lastEventKey?: string;
lastDeltaSignature?: string;