实现 Codex 风格上下文压缩时间线交互
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
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'] {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user