fix(chat): keep messages chronological across ID rollover
This commit is contained in:
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order.
|
||||||
- **Stability:** a single internal error no longer shuts down the local server, which made the instance unreachable until it was restarted; the error is logged and the server keeps running.
|
- **Stability:** a single internal error no longer shuts down the local server, which made the instance unreachable until it was restarted; the error is logged and the server keeps running.
|
||||||
- Browser: restoring or opening a dev server preview while connected to an instance over a relay or other non-standard address no longer crashes the app; the preview reports the tunnel as unavailable instead.
|
- Browser: restoring or opening a dev server preview while connected to an instance over a relay or other non-standard address no longer crashes the app; the preview reports the tunnel as unavailable instead.
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ export const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.mem
|
|||||||
if (!sessionId || restoringId) return;
|
if (!sessionId || restoringId) return;
|
||||||
setRestoringId(messageId);
|
setRestoringId(messageId);
|
||||||
try {
|
try {
|
||||||
const nextMessage = userMessages.find((message) => message.id > messageId);
|
const messageIndex = userMessages.findIndex((message) => message.id === messageId);
|
||||||
|
const nextMessage = messageIndex >= 0 ? userMessages[messageIndex + 1] : undefined;
|
||||||
if (nextMessage) {
|
if (nextMessage) {
|
||||||
await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true });
|
await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -86,4 +86,20 @@ describe('buildRevertedMessageDockState', () => {
|
|||||||
expect(second).not.toBe(first);
|
expect(second).not.toBe(first);
|
||||||
expect(second.records).toHaveLength(1);
|
expect(second.records).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collects a post-rollover reverted tail by marker position', () => {
|
||||||
|
const before = message('msg_ffffffffffffBefore', 'user');
|
||||||
|
const marker = message('msg_000000000000Marker', 'user');
|
||||||
|
const after = message('msg_000000000001After', 'user');
|
||||||
|
|
||||||
|
const snapshot = buildRevertedMessageDockState(
|
||||||
|
state({
|
||||||
|
session: [{ id: 'ses_1', revert: { messageID: marker.id } } as State['session'][number]],
|
||||||
|
message: { ses_1: [before, marker, after] },
|
||||||
|
}),
|
||||||
|
'ses_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot.records.map((record) => record.message.id)).toEqual([marker.id, after.id]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||||
import type { State } from '@/sync/types';
|
import type { State } from '@/sync/types';
|
||||||
|
import { findMessageIndex } from '@/sync/message-ordering';
|
||||||
|
|
||||||
type RevertedMessageRecord = {
|
type RevertedMessageRecord = {
|
||||||
message: Message & { role: 'user' };
|
message: Message & { role: 'user' };
|
||||||
@@ -50,9 +51,14 @@ export const buildRevertedMessageDockState = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages = state.message[sessionId] ?? [];
|
const messages = state.message[sessionId] ?? [];
|
||||||
|
const revertIndex = findMessageIndex(messages, revertMessageID);
|
||||||
|
if (revertIndex < 0) {
|
||||||
|
return EMPTY_REVERTED_MESSAGE_DOCK_STATE;
|
||||||
|
}
|
||||||
const records: RevertedMessageRecord[] = [];
|
const records: RevertedMessageRecord[] = [];
|
||||||
for (const message of messages) {
|
for (let index = revertIndex; index < messages.length; index += 1) {
|
||||||
if (!isUserMessage(message) || message.id < revertMessageID) {
|
const message = messages[index];
|
||||||
|
if (!isUserMessage(message)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
records.push({
|
records.push({
|
||||||
|
|||||||
@@ -182,8 +182,10 @@ Rules:
|
|||||||
6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers.
|
6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers.
|
||||||
7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree.
|
7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree.
|
||||||
8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
|
8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
|
||||||
|
9. Transcript arrays are chronological by `message.time.created`, with message ID used only as a deterministic equal-time tie-breaker. Message IDs are identity and reconciliation keys, not chronology: OpenCode's fixed-width sortable timestamp prefix rolls over, so a newer `msg_000...` can follow an older `msg_fff...`. Fetch, pagination, materialization, optimistic insertion, events, reconnect inspection, rendering, and revert/undo/redo must preserve this contract.
|
||||||
|
10. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted.
|
||||||
|
|
||||||
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication.
|
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering.
|
||||||
|
|
||||||
## Loading diagnostics
|
## Loading diagnostics
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
import type { Event, Part, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
import type { Event, Message, Part, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
import { applyDirectoryEvent } from "../event-reducer"
|
import { applyDirectoryEvent } from "../event-reducer"
|
||||||
import { INITIAL_STATE, type State } from "../types"
|
import { INITIAL_STATE, type State } from "../types"
|
||||||
|
|
||||||
@@ -65,6 +65,55 @@ function buildSession(title: string, time: Session["time"]): Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("applyDirectoryEvent", () => {
|
describe("applyDirectoryEvent", () => {
|
||||||
|
test("inserts post-rollover message events by creation time rather than ID", () => {
|
||||||
|
const legacy = {
|
||||||
|
id: "msg_ffffffffffffLegacy",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 100 },
|
||||||
|
} as Message
|
||||||
|
const current = {
|
||||||
|
id: "msg_000000000000Current",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 200 },
|
||||||
|
} as Message
|
||||||
|
const draft = state({ message: { ses_1: [legacy] } })
|
||||||
|
|
||||||
|
expect(applyDirectoryEvent(draft, {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: { info: current },
|
||||||
|
} as Event)).toBe(true)
|
||||||
|
expect(draft.message.ses_1).toEqual([legacy, current])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves part event order across the part ID rollover", () => {
|
||||||
|
const legacyPart = {
|
||||||
|
id: "prt_ffffffffffffLegacy",
|
||||||
|
messageID: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
type: "text",
|
||||||
|
text: "legacy",
|
||||||
|
} as Part
|
||||||
|
const currentPart = {
|
||||||
|
id: "prt_000000000000Current",
|
||||||
|
messageID: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
type: "text",
|
||||||
|
text: "current",
|
||||||
|
} as Part
|
||||||
|
const draft = state({
|
||||||
|
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "assistant", time: { created: 1 } } as Message] },
|
||||||
|
part: { msg_1: [legacyPart] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(applyDirectoryEvent(draft, {
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: { part: currentPart },
|
||||||
|
} as Event)).toBe(true)
|
||||||
|
expect(draft.part.msg_1).toEqual([legacyPart, currentPart])
|
||||||
|
})
|
||||||
|
|
||||||
test("returns typed materialization when delta arrives before parts", () => {
|
test("returns typed materialization when delta arrives before parts", () => {
|
||||||
const result = applyDirectoryEvent(state(), deltaEvent())
|
const result = applyDirectoryEvent(state(), deltaEvent())
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import { dropSessionCaches } from "./session-cache"
|
|||||||
import { stripSessionDiffSnapshots } from "./sanitize"
|
import { stripSessionDiffSnapshots } from "./sanitize"
|
||||||
import { syncDebug } from "./debug"
|
import { syncDebug } from "./debug"
|
||||||
import { shouldSkipStaleSessionEvent } from "./session-event-freshness"
|
import { shouldSkipStaleSessionEvent } from "./session-event-freshness"
|
||||||
|
import {
|
||||||
|
compareMessagesChronologically,
|
||||||
|
findMessageIndex,
|
||||||
|
insertMessageChronologically,
|
||||||
|
} from "./message-ordering"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const DELTA_OVERLAP_FIELDS = ["text", "output"] as const
|
const DELTA_OVERLAP_FIELDS = ["text", "output"] as const
|
||||||
@@ -180,7 +185,7 @@ function hasMessage(draft: State, sessionID: string | undefined, messageID: stri
|
|||||||
if (!sessionID) return false
|
if (!sessionID) return false
|
||||||
const messages = draft.message[sessionID]
|
const messages = draft.message[sessionID]
|
||||||
if (!messages) return false
|
if (!messages) return false
|
||||||
return Binary.search(messages, messageID, (message) => message.id).found
|
return messages.some((message) => message.id === messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reduceGlobalEvent(event: Event): GlobalEventResult {
|
export function reduceGlobalEvent(event: Event): GlobalEventResult {
|
||||||
@@ -353,21 +358,26 @@ export function applyDirectoryEvent(
|
|||||||
draft.message[info.sessionID] = [info]
|
draft.message[info.sessionID] = [info]
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const result = Binary.search(messages, info.id, (m) => m.id)
|
const messageIndex = findMessageIndex(messages, info.id)
|
||||||
if (result.found) {
|
if (messageIndex >= 0) {
|
||||||
// Skip message replacement if unchanged — preserves reference, avoids re-render
|
// Skip message replacement if unchanged — preserves reference, avoids re-render
|
||||||
const existing = messages[result.index]
|
const existing = messages[messageIndex]
|
||||||
const unchanged = areMessageUpdateFieldsEqual(existing, info)
|
const unchanged = areMessageUpdateFieldsEqual(existing, info)
|
||||||
if (unchanged) {
|
if (unchanged) {
|
||||||
syncDebug.reducer.messageUpdatedUnchanged(info.sessionID, info.id, info.role, (info as { finish?: unknown }).finish, (info.time as { completed?: number })?.completed)
|
syncDebug.reducer.messageUpdatedUnchanged(info.sessionID, info.id, info.role, (info as { finish?: unknown }).finish, (info.time as { completed?: number })?.completed)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const next = [...messages]
|
const next = [...messages]
|
||||||
next[result.index] = info
|
if (compareMessagesChronologically(existing, info) === 0) {
|
||||||
|
next[messageIndex] = info
|
||||||
|
} else {
|
||||||
|
next.splice(messageIndex, 1)
|
||||||
|
insertMessageChronologically(next, info)
|
||||||
|
}
|
||||||
draft.message[info.sessionID] = next
|
draft.message[info.sessionID] = next
|
||||||
} else {
|
} else {
|
||||||
const next = [...messages]
|
const next = [...messages]
|
||||||
next.splice(result.index, 0, info)
|
insertMessageChronologically(next, info)
|
||||||
draft.message[info.sessionID] = next
|
draft.message[info.sessionID] = next
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -378,9 +388,9 @@ export function applyDirectoryEvent(
|
|||||||
const messages = draft.message[props.sessionID]
|
const messages = draft.message[props.sessionID]
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const next = [...messages]
|
const next = [...messages]
|
||||||
const result = Binary.search(next, props.messageID, (m) => m.id)
|
const messageIndex = findMessageIndex(next, props.messageID)
|
||||||
if (result.found) {
|
if (messageIndex >= 0) {
|
||||||
next.splice(result.index, 1)
|
next.splice(messageIndex, 1)
|
||||||
draft.message[props.sessionID] = next
|
draft.message[props.sessionID] = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,14 +421,14 @@ export function applyDirectoryEvent(
|
|||||||
: true
|
: true
|
||||||
}
|
}
|
||||||
const next = [...parts]
|
const next = [...parts]
|
||||||
const result = Binary.search(next, part.id, (p) => p.id)
|
const partIndex = next.findIndex((candidate) => candidate.id === part.id)
|
||||||
if (result.found) {
|
if (partIndex >= 0) {
|
||||||
const previous = next[result.index]
|
const previous = next[partIndex]
|
||||||
if (shouldPreserveExistingPart(previous, part)) {
|
if (shouldPreserveExistingPart(previous, part)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const dedupeFields = getUpdatedDeltaFields(previous, part)
|
const dedupeFields = getUpdatedDeltaFields(previous, part)
|
||||||
next[result.index] = dedupeFields.length > 0
|
next[partIndex] = dedupeFields.length > 0
|
||||||
? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part
|
? { ...part, __dedupeNextDeltaFields: dedupeFields } as unknown as Part
|
||||||
: part
|
: part
|
||||||
} else {
|
} else {
|
||||||
@@ -427,14 +437,13 @@ export function applyDirectoryEvent(
|
|||||||
// always inserted first). Assistant messages never have optimistic parts,
|
// always inserted first). Assistant messages never have optimistic parts,
|
||||||
// so this check is effectively free during streaming.
|
// so this check is effectively free during streaming.
|
||||||
const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID
|
const hasOptimistic = next.length > 0 && !(next[0] as { sessionID?: string }).sessionID
|
||||||
const optimisticIdx = hasOptimistic && (part.type === "text" || part.type === "file")
|
const optimisticIndex = hasOptimistic && (part.type === "text" || part.type === "file")
|
||||||
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
|
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
|
||||||
: -1
|
: -1
|
||||||
if (optimisticIdx >= 0) {
|
if (optimisticIndex >= 0) {
|
||||||
next.splice(optimisticIdx, 1)
|
next.splice(optimisticIndex, 1)
|
||||||
}
|
}
|
||||||
const insertResult = Binary.search(next, part.id, (p) => p.id)
|
next.push(part)
|
||||||
next.splice(insertResult.index, 0, part)
|
|
||||||
}
|
}
|
||||||
draft.part[messageID] = next
|
draft.part[messageID] = next
|
||||||
return missingOwningMessage
|
return missingOwningMessage
|
||||||
@@ -449,10 +458,10 @@ export function applyDirectoryEvent(
|
|||||||
const props = event.properties as { messageID: string; partID: string }
|
const props = event.properties as { messageID: string; partID: string }
|
||||||
const parts = draft.part[props.messageID]
|
const parts = draft.part[props.messageID]
|
||||||
if (!parts) return false
|
if (!parts) return false
|
||||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
const partIndex = parts.findIndex((part) => part.id === props.partID)
|
||||||
if (result.found) {
|
if (partIndex >= 0) {
|
||||||
const next = [...parts]
|
const next = [...parts]
|
||||||
next.splice(result.index, 1)
|
next.splice(partIndex, 1)
|
||||||
if (next.length === 0) {
|
if (next.length === 0) {
|
||||||
delete draft.part[props.messageID]
|
delete draft.part[props.messageID]
|
||||||
} else {
|
} else {
|
||||||
@@ -479,21 +488,21 @@ export function applyDirectoryEvent(
|
|||||||
materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
|
materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
const partIndex = parts.findIndex((part) => part.id === props.partID)
|
||||||
if (!result.found) {
|
if (partIndex < 0) {
|
||||||
syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID)
|
syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID)
|
||||||
return {
|
return {
|
||||||
changed: false,
|
changed: false,
|
||||||
materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
|
materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const existing = parts[result.index] as Record<string, unknown>
|
const existing = parts[partIndex] as Record<string, unknown>
|
||||||
const existingValue = existing[props.field] as string | undefined
|
const existingValue = existing[props.field] as string | undefined
|
||||||
const dedupeFields = (existing as DedupeMetadata).__dedupeNextDeltaFields ?? []
|
const dedupeFields = (existing as DedupeMetadata).__dedupeNextDeltaFields ?? []
|
||||||
const shouldDedupe = dedupeFields.includes(props.field)
|
const shouldDedupe = dedupeFields.includes(props.field)
|
||||||
// Create new Part object + new array so React detects the change
|
// Create new Part object + new array so React detects the change
|
||||||
const next = [...parts]
|
const next = [...parts]
|
||||||
next[result.index] = {
|
next[partIndex] = {
|
||||||
...existing,
|
...existing,
|
||||||
[props.field]: shouldDedupe ? appendNonOverlappingDelta(existingValue, props.delta) : (existingValue ?? "") + props.delta,
|
[props.field]: shouldDedupe ? appendNonOverlappingDelta(existingValue, props.delta) : (existingValue ?? "") + props.delta,
|
||||||
__dedupeNextDeltaFields: dedupeFields.filter((field) => field !== props.field),
|
__dedupeNextDeltaFields: dedupeFields.filter((field) => field !== props.field),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { mergeMessages } from "./optimistic"
|
import { mergeMessages } from "./optimistic"
|
||||||
import type { SessionMaterializationReason } from "./event-reducer"
|
import type { SessionMaterializationReason } from "./event-reducer"
|
||||||
|
import { sortMessagesChronologically } from "./message-ordering"
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
|
||||||
const STREAMING_PART_FIELDS = ["text", "output"] as const
|
const STREAMING_PART_FIELDS = ["text", "output"] as const
|
||||||
const ACTIVE_TOOL_STATUSES = new Set(["pending", "running"])
|
const ACTIVE_TOOL_STATUSES = new Set(["pending", "running"])
|
||||||
const FINAL_TOOL_STATUSES = new Set(["completed", "error", "aborted", "failed", "timeout", "cancelled"])
|
const FINAL_TOOL_STATUSES = new Set(["completed", "error", "aborted", "failed", "timeout", "cancelled"])
|
||||||
@@ -93,10 +93,9 @@ export function getStaleRunningToolMessageID(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortParts(parts: Part[], skipPartTypes: ReadonlySet<string>) {
|
function filterMaterializedParts(parts: Part[], skipPartTypes: ReadonlySet<string>): Part[] {
|
||||||
return parts
|
return parts
|
||||||
.filter((part) => !!part?.id && !skipPartTypes.has(part.type))
|
.filter((part) => !!part?.id && !skipPartTypes.has(part.type))
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
|
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
|
||||||
@@ -252,7 +251,7 @@ function mergeMaterializedParts(
|
|||||||
)
|
)
|
||||||
if (missingLiveParts.length === 0) return mergedParts
|
if (missingLiveParts.length === 0) return mergedParts
|
||||||
|
|
||||||
return [...mergedParts, ...missingLiveParts].sort((a, b) => cmp(a.id, b.id))
|
return [...mergedParts, ...missingLiveParts]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function materializeSessionSnapshots(
|
export function materializeSessionSnapshots(
|
||||||
@@ -262,10 +261,13 @@ export function materializeSessionSnapshots(
|
|||||||
options: MaterializeSessionSnapshotsOptions = {},
|
options: MaterializeSessionSnapshotsOptions = {},
|
||||||
): MaterializeSessionSnapshotsResult {
|
): MaterializeSessionSnapshotsResult {
|
||||||
const skipPartTypes = options.skipPartTypes ?? new Set<string>()
|
const skipPartTypes = options.skipPartTypes ?? new Set<string>()
|
||||||
const snapshots = records
|
const recordsByMessageID = new Map(
|
||||||
|
records
|
||||||
.filter((record) => !!record?.info?.id)
|
.filter((record) => !!record?.info?.id)
|
||||||
.sort((left, right) => cmp(left.info.id, right.info.id))
|
.map((record) => [record.info.id, record] as const),
|
||||||
const nextMessages = snapshots.map((record) => record.info)
|
)
|
||||||
|
const nextMessages = sortMessagesChronologically([...recordsByMessageID.values()].map((record) => record.info))
|
||||||
|
const snapshots = nextMessages.map((message) => recordsByMessageID.get(message.id)!)
|
||||||
const existingMessages = state.message[sessionID]
|
const existingMessages = state.message[sessionID]
|
||||||
const currentMessages = existingMessages ?? []
|
const currentMessages = existingMessages ?? []
|
||||||
const messages = mergeMessages(currentMessages, nextMessages)
|
const messages = mergeMessages(currentMessages, nextMessages)
|
||||||
@@ -283,7 +285,7 @@ export function materializeSessionSnapshots(
|
|||||||
const existing = nextPartState[messageID]
|
const existing = nextPartState[messageID]
|
||||||
const nextParts = mergeMaterializedParts(
|
const nextParts = mergeMaterializedParts(
|
||||||
existing,
|
existing,
|
||||||
sortParts(record.parts ?? [], skipPartTypes),
|
filterMaterializedParts(record.parts ?? [], skipPartTypes),
|
||||||
skipPartTypes,
|
skipPartTypes,
|
||||||
isAssistant,
|
isAssistant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import {
|
||||||
|
insertMessageChronologically,
|
||||||
|
messagesBefore,
|
||||||
|
messagesFrom,
|
||||||
|
sortMessagesChronologically,
|
||||||
|
} from "./message-ordering"
|
||||||
|
|
||||||
|
const message = (id: string, created: number): Message => ({
|
||||||
|
id,
|
||||||
|
sessionID: "session-a",
|
||||||
|
role: "user",
|
||||||
|
time: { created },
|
||||||
|
} as Message)
|
||||||
|
|
||||||
|
describe("message chronology", () => {
|
||||||
|
test("orders post-rollover IDs after legacy IDs by creation time", () => {
|
||||||
|
const legacy = message("msg_ffffffffffffLegacy", 100)
|
||||||
|
const current = message("msg_000000000000Current", 200)
|
||||||
|
|
||||||
|
expect(sortMessagesChronologically([current, legacy])).toEqual([legacy, current])
|
||||||
|
|
||||||
|
const messages = [legacy]
|
||||||
|
insertMessageChronologically(messages, current)
|
||||||
|
expect(messages).toEqual([legacy, current])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses ID only as a deterministic equal-time tie breaker", () => {
|
||||||
|
const second = message("msg_b", 100)
|
||||||
|
const first = message("msg_a", 100)
|
||||||
|
expect(sortMessagesChronologically([second, first])).toEqual([first, second])
|
||||||
|
|
||||||
|
const messages = [second]
|
||||||
|
insertMessageChronologically(messages, first)
|
||||||
|
expect(messages).toEqual([first, second])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("splits a revert branch by marker position instead of ID value", () => {
|
||||||
|
const before = message("msg_ffffBefore", 100)
|
||||||
|
const marker = message("msg_0000Marker", 200)
|
||||||
|
const after = message("msg_0001After", 300)
|
||||||
|
const messages = [before, marker, after]
|
||||||
|
|
||||||
|
expect(messagesBefore(messages, marker.id)).toEqual([before])
|
||||||
|
expect(messagesFrom(messages, marker.id)).toEqual([marker, after])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not destructively split when the marker is not materialized", () => {
|
||||||
|
const messages = [message("msg_a", 100)]
|
||||||
|
expect(messagesBefore(messages, "missing")).toBe(messages)
|
||||||
|
expect(messagesFrom(messages, "missing")).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
const getCreatedAt = (message: Message): number => {
|
||||||
|
const value = (message as { time?: { created?: unknown } }).time?.created
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message IDs identify records; they are not chronology. OpenCode's sortable
|
||||||
|
* ID timestamp rolls over, so a newly created `msg_000...` can follow a legacy
|
||||||
|
* `msg_fff...`. Creation time is the authoritative transcript order, with ID
|
||||||
|
* used only to make equal timestamps deterministic.
|
||||||
|
*/
|
||||||
|
export const compareMessagesChronologically = (left: Message, right: Message): number => {
|
||||||
|
const createdAtDifference = getCreatedAt(left) - getCreatedAt(right)
|
||||||
|
if (createdAtDifference !== 0) return createdAtDifference
|
||||||
|
if (left.id < right.id) return -1
|
||||||
|
if (left.id > right.id) return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortMessagesChronologically = <T extends Message>(messages: readonly T[]): T[] => (
|
||||||
|
[...messages].sort(compareMessagesChronologically)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const findMessageIndex = (messages: readonly Message[], messageID: string): number => (
|
||||||
|
messages.findIndex((message) => message.id === messageID)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const insertMessageChronologically = <T extends Message>(messages: T[], message: T): number => {
|
||||||
|
let low = 0
|
||||||
|
let high = messages.length
|
||||||
|
while (low < high) {
|
||||||
|
const middle = (low + high) >>> 1
|
||||||
|
if (compareMessagesChronologically(messages[middle], message) < 0) {
|
||||||
|
low = middle + 1
|
||||||
|
} else {
|
||||||
|
high = middle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.splice(low, 0, message)
|
||||||
|
return low
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the messages before the marker's current array position. */
|
||||||
|
export const messagesBefore = <T extends Message>(messages: readonly T[], messageID?: string): T[] => {
|
||||||
|
if (!messageID) return messages as T[]
|
||||||
|
const index = findMessageIndex(messages, messageID)
|
||||||
|
return index < 0 ? messages as T[] : messages.slice(0, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the marker and all messages after its current array position. */
|
||||||
|
export const messagesFrom = <T extends Message>(messages: readonly T[], messageID?: string): T[] => {
|
||||||
|
if (!messageID) return []
|
||||||
|
const index = findMessageIndex(messages, messageID)
|
||||||
|
return index < 0 ? [] : messages.slice(index)
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Binary } from "./binary"
|
import { sortMessagesChronologically } from "./message-ordering"
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
function filterIdentifiedParts(parts: Part[]): Part[] {
|
||||||
|
return parts.filter((part) => !!part?.id)
|
||||||
function sortParts(parts: Part[]) {
|
|
||||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OptimisticItem = {
|
export type OptimisticItem = {
|
||||||
@@ -19,22 +17,24 @@ export type MessagePage = {
|
|||||||
complete: boolean
|
complete: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
const containsAllPartsByID = (currentParts: Part[] | undefined, requiredParts: Part[]) => {
|
||||||
if (!parts) return want.length === 0
|
if (!currentParts) return requiredParts.length === 0
|
||||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
const currentPartIDs = new Set(currentParts.map((part) => part.id))
|
||||||
|
return requiredParts.every((part) => currentPartIDs.has(part.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
const mergeParts = (currentParts: Part[] | undefined, optimisticParts: Part[]) => {
|
||||||
if (!parts) return sortParts(want)
|
if (!currentParts) return filterIdentifiedParts(optimisticParts)
|
||||||
const next = [...parts]
|
const next = [...currentParts]
|
||||||
|
const partIDs = new Set(currentParts.map((part) => part.id))
|
||||||
let changed = false
|
let changed = false
|
||||||
for (const part of want) {
|
for (const part of optimisticParts) {
|
||||||
const result = Binary.search(next, part.id, (item) => item.id)
|
if (partIDs.has(part.id)) continue
|
||||||
if (result.found) continue
|
partIDs.add(part.id)
|
||||||
next.splice(result.index, 0, part)
|
next.push(part)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if (!changed) return parts
|
if (!changed) return currentParts
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,46 +42,47 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
|
|||||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||||
|
|
||||||
const session = [...page.session]
|
const session = [...page.session]
|
||||||
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
|
const messageIDs = new Set(session.map((message) => message.id))
|
||||||
|
const partsByMessageID = new Map(page.part.map((item) => [item.id, filterIdentifiedParts(item.part)]))
|
||||||
const confirmed: string[] = []
|
const confirmed: string[] = []
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
const messageExists = messageIDs.has(item.message.id)
|
||||||
const found = result.found
|
if (!messageExists) {
|
||||||
if (!found) session.splice(result.index, 0, item.message)
|
messageIDs.add(item.message.id)
|
||||||
|
session.push(item.message)
|
||||||
|
}
|
||||||
|
|
||||||
const current = part.get(item.message.id)
|
const currentParts = partsByMessageID.get(item.message.id)
|
||||||
if (found && hasParts(current, item.parts)) {
|
if (messageExists && containsAllPartsByID(currentParts, item.parts)) {
|
||||||
confirmed.push(item.message.id)
|
confirmed.push(item.message.id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
part.set(item.message.id, mergeParts(current, item.parts))
|
partsByMessageID.set(item.message.id, mergeParts(currentParts, item.parts))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cursor: page.cursor,
|
cursor: page.cursor,
|
||||||
complete: page.complete,
|
complete: page.complete,
|
||||||
session,
|
session: sortMessagesChronologically(session),
|
||||||
part: [...part.entries()]
|
part: [...partsByMessageID].map(([id, part]) => ({ id, part })),
|
||||||
.sort((a, b) => cmp(a[0], b[0]))
|
|
||||||
.map(([id, part]) => ({ id, part })),
|
|
||||||
confirmed,
|
confirmed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge two sorted message arrays by id, deduplicating.
|
/** Merge two chronologically sorted message arrays by identity, deduplicating.
|
||||||
* Preserves references from `a` for items that already exist — avoids
|
* Preserves existing references for items that already exist — avoids
|
||||||
* unnecessary React re-renders when prepending older history. */
|
* unnecessary React re-renders when prepending older history. */
|
||||||
export function mergeMessages<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
export function mergeMessages<T extends Message>(existingMessages: readonly T[], incomingMessages: readonly T[]) {
|
||||||
const existing = new Map(a.map((item) => [item.id, item] as const))
|
const messagesByID = new Map(existingMessages.map((item) => [item.id, item] as const))
|
||||||
let changed = false
|
let changed = false
|
||||||
for (const item of b) {
|
for (const item of incomingMessages) {
|
||||||
if (!existing.has(item.id)) {
|
if (!messagesByID.has(item.id)) {
|
||||||
existing.set(item.id, item)
|
messagesByID.set(item.id, item)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!changed) return a as T[]
|
if (!changed) return existingMessages as T[]
|
||||||
return [...existing.values()].sort((x, y) => cmp(x.id, y.id))
|
return sortMessagesChronologically([...messagesByID.values()])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -944,12 +944,12 @@ describe("optimisticSend target directory", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("commits the new branch locally and discards its optimistic shadow when sending after a revert", async () => {
|
test("commits the new branch locally and discards its optimistic shadow when sending after a revert", async () => {
|
||||||
const retainedMessage = { id: "msg_1", role: "user", sessionID: "session-reverted" } as Message
|
const retainedMessage = { id: "msg_ffffffffffffRetained", role: "user", sessionID: "session-reverted", time: { created: 1 } } as Message
|
||||||
const revertedMessage = { id: "msg_2", role: "user", sessionID: "session-reverted" } as Message
|
const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message
|
||||||
const targetStore = createStore({}, {
|
const targetStore = createStore({}, {
|
||||||
session: [{ id: "session-reverted", revert: { messageID: "msg_2" } } as Session],
|
session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session],
|
||||||
message: { "session-reverted": [retainedMessage, revertedMessage] },
|
message: { "session-reverted": [retainedMessage, revertedMessage] },
|
||||||
part: { msg_2: [{ id: "part_2", type: "text", text: "old branch" } as Part] },
|
part: { [revertedMessage.id]: [{ id: "part_2", type: "text", text: "old branch" } as Part] },
|
||||||
})
|
})
|
||||||
const childStores = createChildStores([["/target/project", targetStore]])
|
const childStores = createChildStores([["/target/project", targetStore]])
|
||||||
let optimisticMessage: Message | null = null
|
let optimisticMessage: Message | null = null
|
||||||
@@ -981,22 +981,22 @@ describe("optimisticSend target directory", () => {
|
|||||||
|
|
||||||
expect(targetStore.getState().session[0].revert).toBe(undefined)
|
expect(targetStore.getState().session[0].revert).toBe(undefined)
|
||||||
expect(targetStore.getState().message["session-reverted"].map((message) => message.id)).toEqual([
|
expect(targetStore.getState().message["session-reverted"].map((message) => message.id)).toEqual([
|
||||||
"msg_1",
|
retainedMessage.id,
|
||||||
(optimisticMessage as unknown as Message).id,
|
(optimisticMessage as unknown as Message).id,
|
||||||
])
|
])
|
||||||
expect(targetStore.getState().part.msg_2).toBe(undefined)
|
expect(targetStore.getState().part[revertedMessage.id]).toBe(undefined)
|
||||||
expect(optimisticShadow.has(revertedMessage.id)).toBe(false)
|
expect(optimisticShadow.has(revertedMessage.id)).toBe(false)
|
||||||
expect(optimisticShadow.has((optimisticMessage as unknown as Message).id)).toBe(true)
|
expect(optimisticShadow.has((optimisticMessage as unknown as Message).id)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("restores the reverted branch when sending fails", async () => {
|
test("restores the reverted branch when sending fails", async () => {
|
||||||
const retainedMessage = { id: "msg_1", role: "user", sessionID: "session-reverted" } as Message
|
const retainedMessage = { id: "msg_ffffffffffffRetained", role: "user", sessionID: "session-reverted", time: { created: 1 } } as Message
|
||||||
const revertedMessage = { id: "msg_2", role: "user", sessionID: "session-reverted" } as Message
|
const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message
|
||||||
const revertedPart = { id: "part_2", type: "text", text: "old branch" } as Part
|
const revertedPart = { id: "part_2", type: "text", text: "old branch" } as Part
|
||||||
const targetStore = createStore({}, {
|
const targetStore = createStore({}, {
|
||||||
session: [{ id: "session-reverted", revert: { messageID: "msg_2" } } as Session],
|
session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session],
|
||||||
message: { "session-reverted": [retainedMessage, revertedMessage] },
|
message: { "session-reverted": [retainedMessage, revertedMessage] },
|
||||||
part: { msg_2: [revertedPart] },
|
part: { [revertedMessage.id]: [revertedPart] },
|
||||||
})
|
})
|
||||||
const childStores = createChildStores([["/target/project", targetStore]])
|
const childStores = createChildStores([["/target/project", targetStore]])
|
||||||
|
|
||||||
@@ -1022,9 +1022,9 @@ describe("optimisticSend target directory", () => {
|
|||||||
send: async () => { throw new Error("rejected") },
|
send: async () => { throw new Error("rejected") },
|
||||||
})).rejects.toThrow("rejected")
|
})).rejects.toThrow("rejected")
|
||||||
|
|
||||||
expect(targetStore.getState().session[0].revert?.messageID).toBe("msg_2")
|
expect(targetStore.getState().session[0].revert?.messageID).toBe(revertedMessage.id)
|
||||||
expect(targetStore.getState().message["session-reverted"]).toEqual([retainedMessage, revertedMessage])
|
expect(targetStore.getState().message["session-reverted"]).toEqual([retainedMessage, revertedMessage])
|
||||||
expect(targetStore.getState().part.msg_2).toEqual([revertedPart])
|
expect(targetStore.getState().part[revertedMessage.id]).toEqual([revertedPart])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rolls back a captured send when the runtime changes after optimistic insert", async () => {
|
test("rolls back a captured send when the runtime changes after optimistic insert", async () => {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { getRuntimeKey } from "@/lib/runtime-switch"
|
|||||||
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
import { isAmbiguousTransportFailure } from "@/lib/relay/transport-error"
|
||||||
import { getStaleRunningToolMessageID } from "./materialization"
|
import { getStaleRunningToolMessageID } from "./materialization"
|
||||||
import { normalizePath } from "@/lib/pathNormalization"
|
import { normalizePath } from "@/lib/pathNormalization"
|
||||||
|
import { mergeMessages } from "./optimistic"
|
||||||
|
import { messagesBefore, messagesFrom } from "./message-ordering"
|
||||||
|
|
||||||
const MESSAGE_REFETCH_LIMIT = 100
|
const MESSAGE_REFETCH_LIMIT = 100
|
||||||
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
|
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
|
||||||
@@ -1230,9 +1232,10 @@ export async function unshareSession(sessionId: string): Promise<Session | null>
|
|||||||
// Optimistic message send — insert user message before API call, rollback on error
|
// Optimistic message send — insert user message before API call, rollback on error
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// ID generator matching OpenCode's Identifier.ascending format.
|
// ID generator matching OpenCode's Identifier.ascending wire format.
|
||||||
// Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62.
|
// Uses BigInt(timestamp) * 0x1000 + counter, encoded as 6 hex bytes + random base62.
|
||||||
// This ensures client-generated IDs sort correctly with server-generated ones.
|
// The 6-byte prefix rolls over, so this value is identity only; transcript
|
||||||
|
// chronology is always derived from message.time.created.
|
||||||
let lastIdTimestamp = 0
|
let lastIdTimestamp = 0
|
||||||
let idCounter = 0
|
let idCounter = 0
|
||||||
|
|
||||||
@@ -1308,9 +1311,8 @@ export async function optimisticSend(input: {
|
|||||||
const stateBeforeSend = store.getState()
|
const stateBeforeSend = store.getState()
|
||||||
const sessionBeforeSend = stateBeforeSend.session.find((session) => session.id === input.sessionId)
|
const sessionBeforeSend = stateBeforeSend.session.find((session) => session.id === input.sessionId)
|
||||||
const revertMessageID = sessionBeforeSend?.revert?.messageID
|
const revertMessageID = sessionBeforeSend?.revert?.messageID
|
||||||
const revertedMessages = revertMessageID
|
const messagesBeforeSend = stateBeforeSend.message[input.sessionId] ?? []
|
||||||
? (stateBeforeSend.message[input.sessionId] ?? []).filter((message) => message.id >= revertMessageID)
|
const revertedMessages = messagesFrom(messagesBeforeSend, revertMessageID)
|
||||||
: []
|
|
||||||
const revertedParts = new Map(
|
const revertedParts = new Map(
|
||||||
revertedMessages.map((message) => [message.id, stateBeforeSend.part[message.id] ?? []] as const),
|
revertedMessages.map((message) => [message.id, stateBeforeSend.part[message.id] ?? []] as const),
|
||||||
)
|
)
|
||||||
@@ -1321,7 +1323,7 @@ export async function optimisticSend(input: {
|
|||||||
))
|
))
|
||||||
const message = {
|
const message = {
|
||||||
...stateBeforeSend.message,
|
...stateBeforeSend.message,
|
||||||
[input.sessionId]: (stateBeforeSend.message[input.sessionId] ?? []).filter((candidate) => candidate.id < revertMessageID),
|
[input.sessionId]: messagesBefore(messagesBeforeSend, revertMessageID),
|
||||||
}
|
}
|
||||||
const part = { ...stateBeforeSend.part }
|
const part = { ...stateBeforeSend.part }
|
||||||
for (const revertedMessage of revertedMessages) delete part[revertedMessage.id]
|
for (const revertedMessage of revertedMessages) delete part[revertedMessage.id]
|
||||||
@@ -1439,8 +1441,7 @@ export async function optimisticSend(input: {
|
|||||||
))
|
))
|
||||||
message = {
|
message = {
|
||||||
...rollbackState.message,
|
...rollbackState.message,
|
||||||
[input.sessionId]: [...(rollbackState.message[input.sessionId] ?? []), ...revertedMessages]
|
[input.sessionId]: mergeMessages(rollbackState.message[input.sessionId] ?? [], revertedMessages),
|
||||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
||||||
}
|
}
|
||||||
part = { ...rollbackState.part }
|
part = { ...rollbackState.part }
|
||||||
for (const [revertedMessageID, parts] of revertedParts) {
|
for (const [revertedMessageID, parts] of revertedParts) {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
startSessionLoadPerformanceEvent,
|
startSessionLoadPerformanceEvent,
|
||||||
} from "./session-load-performance"
|
} from "./session-load-performance"
|
||||||
|
|
||||||
const createRecord = (sessionID: string, id = "msg_1") => ({
|
const createRecord = (sessionID: string, id = "msg_1", created = 1) => ({
|
||||||
info: { id, sessionID, role: "user", time: { created: 1 } } as Message,
|
info: { id, sessionID, role: "user", time: { created } } as Message,
|
||||||
parts: [{ id: `part_${id}`, messageID: id, sessionID, type: "text", text: "hello" }] as Part[],
|
parts: [{ id: `part_${id}`, messageID: id, sessionID, type: "text", text: "hello" }] as Part[],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ describe("SessionMessageLoader", () => {
|
|||||||
const { childStores, loader } = createLoader(async ({ sessionID, limit, before }) => {
|
const { childStores, loader } = createLoader(async ({ sessionID, limit, before }) => {
|
||||||
calls.push({ limit, before })
|
calls.push({ limit, before })
|
||||||
return before
|
return before
|
||||||
? response([createRecord(sessionID, "msg_older")])
|
? response([createRecord(sessionID, "msg_older", 1)])
|
||||||
: response([createRecord(sessionID, "msg_latest")], "older-cursor")
|
: response([createRecord(sessionID, "msg_latest", 2)], "older-cursor")
|
||||||
})
|
})
|
||||||
const target = { directory: "/repo", sessionID: "session-a" }
|
const target = { directory: "/repo", sessionID: "session-a" }
|
||||||
|
|
||||||
@@ -83,11 +83,35 @@ describe("SessionMessageLoader", () => {
|
|||||||
{ limit: 100, before: "older-cursor" },
|
{ limit: 100, before: "older-cursor" },
|
||||||
])
|
])
|
||||||
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id))
|
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id))
|
||||||
.toEqual(["msg_latest", "msg_older"].sort())
|
.toEqual(["msg_older", "msg_latest"])
|
||||||
loader.dispose()
|
loader.dispose()
|
||||||
childStores.disposeAll()
|
childStores.disposeAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps a post-rollover tail after legacy messages for shared runtime identities", async () => {
|
||||||
|
const runtimes = ["web", "desktop", "vscode", "mobile"]
|
||||||
|
for (const runtimeKey of runtimes) {
|
||||||
|
const childStores = new ChildStoreManager()
|
||||||
|
const sdk = {
|
||||||
|
session: {
|
||||||
|
messages: async ({ sessionID }: { sessionID: string }) => response([
|
||||||
|
createRecord(sessionID, "msg_000000000000Current", 200),
|
||||||
|
createRecord(sessionID, "msg_ffffffffffffLegacy", 100),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient
|
||||||
|
const loader = new SessionMessageLoader(childStores, { sdk, runtimeKey })
|
||||||
|
const target = { directory: `/repo-${runtimeKey}`, sessionID: "session-a" }
|
||||||
|
|
||||||
|
await loader.ensure(target)
|
||||||
|
|
||||||
|
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id))
|
||||||
|
.toEqual(["msg_ffffffffffffLegacy", "msg_000000000000Current"])
|
||||||
|
loader.dispose()
|
||||||
|
childStores.disposeAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("loads every history page for an explicit complete-history request", async () => {
|
test("loads every history page for an explicit complete-history request", async () => {
|
||||||
const calls: Array<{ before?: string }> = []
|
const calls: Array<{ before?: string }> = []
|
||||||
const { childStores, loader } = createLoader(async ({ sessionID, before }) => {
|
const { childStores, loader } = createLoader(async ({ sessionID, before }) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { ChildStoreManager, DirectoryStore } from "./child-store"
|
import type { ChildStoreManager, DirectoryStore } from "./child-store"
|
||||||
import { Binary } from "./binary"
|
|
||||||
import { retry } from "./retry"
|
import { retry } from "./retry"
|
||||||
import { mergeOptimisticPage, type OptimisticItem } from "./optimistic"
|
import { mergeOptimisticPage, type OptimisticItem } from "./optimistic"
|
||||||
|
import { findMessageIndex, insertMessageChronologically, sortMessagesChronologically } from "./message-ordering"
|
||||||
import { stripMessageDiffSnapshots } from "./sanitize"
|
import { stripMessageDiffSnapshots } from "./sanitize"
|
||||||
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +23,6 @@ const CONSTRAINED_INITIAL_MESSAGE_PAGE_SIZE = 30
|
|||||||
const HISTORY_MESSAGE_PAGE_SIZE = 100
|
const HISTORY_MESSAGE_PAGE_SIZE = 100
|
||||||
const INITIAL_PAGE_EXPANSION_LIMITS = [100, 150] as const
|
const INITIAL_PAGE_EXPANSION_LIMITS = [100, 150] as const
|
||||||
const CONSTRAINED_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
|
const CONSTRAINED_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
|
||||||
const cmp = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0
|
|
||||||
|
|
||||||
export type SessionMessageTarget = {
|
export type SessionMessageTarget = {
|
||||||
directory: string
|
directory: string
|
||||||
@@ -109,9 +108,8 @@ const assertSdkSuccess = (result: {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortParts = (parts: Part[]): Part[] => parts
|
const filterIdentifiedParts = (parts: Part[]): Part[] => parts
|
||||||
.filter((part) => Boolean(part?.id))
|
.filter((part) => Boolean(part?.id))
|
||||||
.sort((left, right) => cmp(left.id, right.id))
|
|
||||||
|
|
||||||
const createDefaultState = (generation = 0): SessionMessageLoadState => ({
|
const createDefaultState = (generation = 0): SessionMessageLoadState => ({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
@@ -341,15 +339,16 @@ export class SessionMessageLoader {
|
|||||||
const target = this.normalizeTarget(input)
|
const target = this.normalizeTarget(input)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
const entry = this.getEntry(target)
|
const entry = this.getEntry(target)
|
||||||
entry.optimistic.set(input.message.id, { message: input.message, parts: sortParts(input.parts) })
|
entry.optimistic.set(input.message.id, { message: input.message, parts: filterIdentifiedParts(input.parts) })
|
||||||
const store = this.childStores.ensureChild(target.directory, { bootstrap: false })
|
const store = this.childStores.ensureChild(target.directory, { bootstrap: false })
|
||||||
const current = store.getState()
|
const current = store.getState()
|
||||||
const messages = current.message[target.sessionID] ? [...current.message[target.sessionID]] : []
|
const messages = current.message[target.sessionID] ? [...current.message[target.sessionID]] : []
|
||||||
const result = Binary.search(messages, input.message.id, (message) => message.id)
|
if (findMessageIndex(messages, input.message.id) < 0) {
|
||||||
if (!result.found) messages.splice(result.index, 0, input.message)
|
insertMessageChronologically(messages, input.message)
|
||||||
|
}
|
||||||
store.setState({
|
store.setState({
|
||||||
message: { ...current.message, [target.sessionID]: messages },
|
message: { ...current.message, [target.sessionID]: messages },
|
||||||
part: { ...current.part, [input.message.id]: sortParts(input.parts) },
|
part: { ...current.part, [input.message.id]: filterIdentifiedParts(input.parts) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,12 +600,12 @@ export class SessionMessageLoader {
|
|||||||
const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id))
|
const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id))
|
||||||
recordCount = records.length
|
recordCount = records.length
|
||||||
if (performance) performance.recordCount += recordCount
|
if (performance) performance.recordCount += recordCount
|
||||||
const session = records
|
const session = sortMessagesChronologically(
|
||||||
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
|
records.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info)),
|
||||||
.sort((left: Message, right: Message) => cmp(left.id, right.id))
|
)
|
||||||
const partsByMessageID = new Map<string, Part[]>()
|
const partsByMessageID = new Map<string, Part[]>()
|
||||||
for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) {
|
for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) {
|
||||||
partsByMessageID.set(record.info.id, sortParts(record.parts ?? []))
|
partsByMessageID.set(record.info.id, filterIdentifiedParts(record.parts ?? []))
|
||||||
}
|
}
|
||||||
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
|
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
|
||||||
finishPagePerformance("complete", { retryCount: Math.max(0, attempts - 1), recordCount })
|
finishPagePerformance("complete", { retryCount: Math.max(0, attempts - 1), recordCount })
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
|||||||
import { buildSessionMessageRecordsSnapshot } from './sync-context';
|
import { buildSessionMessageRecordsSnapshot } from './sync-context';
|
||||||
import { INITIAL_STATE, type State } from './types';
|
import { INITIAL_STATE, type State } from './types';
|
||||||
|
|
||||||
const message = (id: string, role: 'user' | 'assistant', parentID?: string): Message => ({
|
const message = (id: string, role: 'user' | 'assistant', parentID?: string, created = 1): Message => ({
|
||||||
id,
|
id,
|
||||||
role,
|
role,
|
||||||
sessionID: 'ses_1',
|
sessionID: 'ses_1',
|
||||||
...(parentID ? { parentID } : {}),
|
...(parentID ? { parentID } : {}),
|
||||||
time: { created: 1 },
|
time: { created },
|
||||||
} as Message);
|
} as Message);
|
||||||
|
|
||||||
const textPart = (id: string, text: string): Part => ({
|
const textPart = (id: string, text: string): Part => ({
|
||||||
@@ -34,6 +34,22 @@ const state = (partial: Partial<State>): State => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('buildSessionMessageRecordsSnapshot', () => {
|
describe('buildSessionMessageRecordsSnapshot', () => {
|
||||||
|
test('renders and reverts a rollover-spanning transcript by array chronology', () => {
|
||||||
|
const before = message('msg_ffffffffffffBefore', 'user', undefined, 100);
|
||||||
|
const marker = message('msg_000000000000Marker', 'user', undefined, 200);
|
||||||
|
const after = message('msg_000000000001After', 'assistant', marker.id, 300);
|
||||||
|
|
||||||
|
const snapshot = buildSessionMessageRecordsSnapshot(
|
||||||
|
state({
|
||||||
|
session: [{ id: 'ses_1', revert: { messageID: marker.id } } as State['session'][number]],
|
||||||
|
message: { ses_1: [before, marker, after] },
|
||||||
|
}),
|
||||||
|
'ses_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot.list.map((record) => record.info.id)).toEqual([before.id]);
|
||||||
|
});
|
||||||
|
|
||||||
test('only suspends part updates for the active streaming message', () => {
|
test('only suspends part updates for the active streaming message', () => {
|
||||||
const user = message('user_1', 'user');
|
const user = message('user_1', 'user');
|
||||||
const assistant1 = message('assistant_1', 'assistant', 'user_1');
|
const assistant1 = message('assistant_1', 'assistant', 'user_1');
|
||||||
|
|||||||
@@ -1490,7 +1490,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
const revertToId = currentSession?.revert?.messageID
|
const revertToId = currentSession?.revert?.messageID
|
||||||
let targetMessage: typeof messages[number] | undefined
|
let targetMessage: typeof messages[number] | undefined
|
||||||
if (revertToId) {
|
if (revertToId) {
|
||||||
targetMessage = [...userMessages].reverse().find((m) => m.id < revertToId)
|
const revertIndex = userMessages.findIndex((message) => message.id === revertToId)
|
||||||
|
targetMessage = revertIndex > 0 ? userMessages[revertIndex - 1] : undefined
|
||||||
} else {
|
} else {
|
||||||
targetMessage = userMessages[userMessages.length - 1]
|
targetMessage = userMessages[userMessages.length - 1]
|
||||||
}
|
}
|
||||||
@@ -1537,7 +1538,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
await refetchSessionMessages(sessionId)
|
await refetchSessionMessages(sessionId)
|
||||||
const messages = getSyncMessages(sessionId)
|
const messages = getSyncMessages(sessionId)
|
||||||
const userMessages = messages.filter((m) => m.role === "user")
|
const userMessages = messages.filter((m) => m.role === "user")
|
||||||
const targetMessage = userMessages.find((m) => m.id > revertToId)
|
const revertIndex = userMessages.findIndex((message) => message.id === revertToId)
|
||||||
|
const targetMessage = revertIndex >= 0 ? userMessages[revertIndex + 1] : undefined
|
||||||
|
|
||||||
if (targetMessage) {
|
if (targetMessage) {
|
||||||
await get().revertToMessage(sessionId, targetMessage.id, { skipRedoPush: true })
|
await get().revertToMessage(sessionId, targetMessage.id, { skipRedoPush: true })
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { stripSessionDiffSnapshots } from "./sanitize"
|
|||||||
import { applySessionEventToGlobalSessions } from "./session-event-router"
|
import { applySessionEventToGlobalSessions } from "./session-event-router"
|
||||||
import { syncDebug } from "./debug"
|
import { syncDebug } from "./debug"
|
||||||
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
|
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
|
||||||
|
import { messagesBefore } from "./message-ordering"
|
||||||
import { opencodeClient } from "@/lib/opencode/client"
|
import { opencodeClient } from "@/lib/opencode/client"
|
||||||
import { usePermissionStore } from "@/stores/permissionStore"
|
import { usePermissionStore } from "@/stores/permissionStore"
|
||||||
import {
|
import {
|
||||||
@@ -2995,7 +2996,7 @@ function getVisibleMessagesForSession(state: State, sessionID: string, previous?
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sourceMessages,
|
sourceMessages,
|
||||||
visibleMessages: revertMessageID ? sourceMessages.filter((message) => message.id < revertMessageID) : sourceMessages,
|
visibleMessages: messagesBefore(sourceMessages, revertMessageID),
|
||||||
revertMessageID,
|
revertMessageID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,11 @@ describe('shouldFetchSessionForRenderableSync', () => {
|
|||||||
// already-committed messages (no re-render churn, no reference breaks).
|
// already-committed messages (no re-render churn, no reference breaks).
|
||||||
// 3. mergeOptimisticPage + clearOptimistic is idempotent across commits.
|
// 3. mergeOptimisticPage + clearOptimistic is idempotent across commits.
|
||||||
|
|
||||||
function assistantMessage(id: string): Message {
|
function assistantMessage(id: string, created = 1): Message {
|
||||||
return { id, sessionID: 'ses_1', role: 'assistant', time: { created: 1 } } as Message
|
return { id, sessionID: 'ses_1', role: 'assistant', time: { created } } as Message
|
||||||
}
|
}
|
||||||
function userMessage(id: string): Message {
|
function userMessage(id: string, created = 1): Message {
|
||||||
return { id, sessionID: 'ses_1', role: 'user', time: { created: 1 } } as Message
|
return { id, sessionID: 'ses_1', role: 'user', time: { created } } as Message
|
||||||
}
|
}
|
||||||
function assistantMessageWithClientRole(id: string): Message {
|
function assistantMessageWithClientRole(id: string): Message {
|
||||||
// OpenCode sets clientRole on the wire; role may be absent.
|
// OpenCode sets clientRole on the wire; role may be absent.
|
||||||
@@ -79,11 +79,26 @@ describe('hasUserMessage', () => {
|
|||||||
describe('incremental materialization of superset pages (#2084)', () => {
|
describe('incremental materialization of superset pages (#2084)', () => {
|
||||||
const SKIP_PARTS = new Set(['patch', 'step-start', 'step-finish'])
|
const SKIP_PARTS = new Set(['patch', 'step-start', 'step-finish'])
|
||||||
|
|
||||||
|
test('preserves authoritative part order across the part ID rollover', () => {
|
||||||
|
const msg = assistantMessage('msg_1')
|
||||||
|
const legacy = textPart('prt_ffffffffffffLegacy', msg.id)
|
||||||
|
const current = textPart('prt_000000000000Current', msg.id)
|
||||||
|
|
||||||
|
const result = materializeSessionSnapshots(
|
||||||
|
{ message: {}, part: {} },
|
||||||
|
'ses_1',
|
||||||
|
[{ info: msg, parts: [legacy, current] }],
|
||||||
|
{ skipPartTypes: SKIP_PARTS },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.part[msg.id]).toEqual([legacy, current])
|
||||||
|
})
|
||||||
|
|
||||||
test('preserves message references for already-committed messages', () => {
|
test('preserves message references for already-committed messages', () => {
|
||||||
// Simulate expansion: commit 50 (assistant-only), then 100 (with user), then 150.
|
// Simulate expansion: commit 50 (assistant-only), then 100 (with user), then 150.
|
||||||
// Pages are supersets: the 100-page includes all 50 from the first page,
|
// Pages are supersets: the 100-page includes all 50 from the first page,
|
||||||
// the 150-page includes all 100 from the second.
|
// the 150-page includes all 100 from the second.
|
||||||
// Messages are sorted by id in the store (mergeMessages uses cmp by id),
|
// Messages are chronological in the store,
|
||||||
// so look them up by id rather than positional index.
|
// so look them up by id rather than positional index.
|
||||||
const a1 = assistantMessage('a_1')
|
const a1 = assistantMessage('a_1')
|
||||||
const a2 = assistantMessage('a_2')
|
const a2 = assistantMessage('a_2')
|
||||||
@@ -168,6 +183,16 @@ describe('incremental materialization of superset pages (#2084)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('mergeOptimisticPage idempotency across commits (#2084)', () => {
|
describe('mergeOptimisticPage idempotency across commits (#2084)', () => {
|
||||||
|
test('places a post-rollover optimistic message at the chronological tail', () => {
|
||||||
|
const legacy = userMessage('msg_ffffffffffffLegacy', 100)
|
||||||
|
const current = userMessage('msg_000000000000Current', 200)
|
||||||
|
const page = { session: [legacy], part: [], cursor: undefined, complete: true }
|
||||||
|
|
||||||
|
const merged = mergeOptimisticPage(page, [{ message: current, parts: [] }])
|
||||||
|
|
||||||
|
expect(merged.session).toEqual([legacy, current])
|
||||||
|
})
|
||||||
|
|
||||||
test('second call after clearOptimistic returns the page unchanged', () => {
|
test('second call after clearOptimistic returns the page unchanged', () => {
|
||||||
// Simulate: first commit confirmed an optimistic item, clearOptimistic
|
// Simulate: first commit confirmed an optimistic item, clearOptimistic
|
||||||
// removed it; second commit (expansion) finds no optimistic items.
|
// removed it; second commit (expansion) finds no optimistic items.
|
||||||
|
|||||||
@@ -78,16 +78,16 @@ describe('buildUserMessageHistorySnapshot', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('excludes user messages hidden by session revert state', () => {
|
test('excludes user messages hidden by session revert state', () => {
|
||||||
const beforeRevert = message('user_1', 'user');
|
const beforeRevert = message('msg_ffffffffffffBefore', 'user');
|
||||||
const reverted = message('user_2', 'user');
|
const reverted = message('msg_000000000000Reverted', 'user');
|
||||||
|
|
||||||
const snapshot = buildUserMessageHistorySnapshot(
|
const snapshot = buildUserMessageHistorySnapshot(
|
||||||
state({
|
state({
|
||||||
session: [{ id: 'ses_1', revert: { messageID: 'user_2' } } as State['session'][number]],
|
session: [{ id: 'ses_1', revert: { messageID: reverted.id } } as State['session'][number]],
|
||||||
message: { ses_1: [beforeRevert, reverted] },
|
message: { ses_1: [beforeRevert, reverted] },
|
||||||
part: {
|
part: {
|
||||||
user_1: [textPart('part_user_1', 'kept')],
|
[beforeRevert.id]: [textPart('part_user_1', 'kept')],
|
||||||
user_2: [textPart('part_user_2', 'reverted')],
|
[reverted.id]: [textPart('part_user_2', 'reverted')],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
'ses_1',
|
'ses_1',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||||
import type { State } from './types';
|
import type { State } from './types';
|
||||||
|
import { messagesBefore } from './message-ordering';
|
||||||
|
|
||||||
type UserMessageHistoryRecord = {
|
type UserMessageHistoryRecord = {
|
||||||
message: Message;
|
message: Message;
|
||||||
@@ -62,14 +63,12 @@ export const buildUserMessageHistorySnapshot = (
|
|||||||
const session = state.session.find((candidate) => candidate.id === sessionID);
|
const session = state.session.find((candidate) => candidate.id === sessionID);
|
||||||
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
|
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
|
||||||
const records: UserMessageHistoryRecord[] = [];
|
const records: UserMessageHistoryRecord[] = [];
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
const visibleMessages = messagesBefore(messages, revertMessageID);
|
||||||
const message = messages[index];
|
for (let index = visibleMessages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = visibleMessages[index];
|
||||||
if (message.role !== 'user') {
|
if (message.role !== 'user') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (revertMessageID && message.id >= revertMessageID) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
records.push({
|
records.push({
|
||||||
message,
|
message,
|
||||||
parts: state.part[message.id] ?? EMPTY_PARTS,
|
parts: state.part[message.id] ?? EMPTY_PARTS,
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
- **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order.
|
||||||
|
|
||||||
## [1.18.3] - 2026-08-14
|
## [1.18.3] - 2026-08-14
|
||||||
|
|
||||||
- Chat images: completed assistant replies now collect Markdown images into a compact gallery with thumbnails and full-screen previews, including workspace-local images across multi-root workspaces (thanks to @ChangeHow).
|
- Chat images: completed assistant replies now collect Markdown images into a compact gallery with thumbnails and full-screen previews, including workspace-local images across multi-root workspaces (thanks to @ChangeHow).
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
|
|||||||
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
|
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
|
||||||
- Serializes reads and read-modify-write updates, persists a monotonic policy revision, and broadcasts the exact committed snapshot to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime.
|
- Serializes reads and read-modify-write updates, persists a monotonic policy revision, and broadcasts the exact committed snapshot to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime.
|
||||||
|
|
||||||
|
## Shared webview message ordering
|
||||||
|
|
||||||
|
Message and part ordering is owned by [`packages/ui/src/sync/DOCUMENTATION.md`](../../ui/src/sync/DOCUMENTATION.md#session-message-loading). The VS Code webview consumes that shared sync implementation; bridge and proxy runtimes pass OpenCode records through without adding runtime-specific ordering.
|
||||||
|
|
||||||
## Extension guideline
|
## Extension guideline
|
||||||
|
|
||||||
When adding new bridge route families:
|
When adding new bridge route families:
|
||||||
|
|||||||
Reference in New Issue
Block a user