feat(ui): cascade undo and redo to subagents
This commit is contained in:
@@ -438,6 +438,76 @@ type SessionListSnapshot = {
|
||||
|
||||
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]>
|
||||
|
||||
type DescendantSession = {
|
||||
session: Session
|
||||
directory: string
|
||||
}
|
||||
|
||||
function getDescendantSessions(rootId: string): DescendantSession[] {
|
||||
const stores = _childStores
|
||||
if (!stores) return []
|
||||
|
||||
const sessionsById = new Map<string, DescendantSession>()
|
||||
for (const [storeDirectory, store] of stores.children) {
|
||||
for (const session of store.getState().session) {
|
||||
const directory = session.directory || storeDirectory
|
||||
const current = sessionsById.get(session.id)
|
||||
if (!current || session.directory) sessionsById.set(session.id, { session, directory })
|
||||
}
|
||||
}
|
||||
|
||||
const subtreeIds = computeSubtreeIds(
|
||||
[...sessionsById.values()].map(({ session }) => session),
|
||||
rootId,
|
||||
)
|
||||
subtreeIds.delete(rootId)
|
||||
return [...subtreeIds]
|
||||
.map((id) => sessionsById.get(id))
|
||||
.filter((entry): entry is DescendantSession => !!entry)
|
||||
}
|
||||
|
||||
function firstUserMessageAtOrAfter(messages: Message[], cutoff: number): Message | null {
|
||||
let target: Message | null = null
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user" || message.time.created < cutoff) continue
|
||||
if (!target || message.time.created < target.time.created) target = message
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
async function fetchSessionMessages(sessionId: string, directory?: string | null): Promise<Message[]> {
|
||||
const records = await opencodeClient.getSessionMessages(sessionId, undefined, directory)
|
||||
return records.map(({ info }) => info)
|
||||
}
|
||||
|
||||
async function cascadeRevertToDescendants(rootId: string, cutoff: number): Promise<void> {
|
||||
for (const { session, directory } of getDescendantSessions(rootId)) {
|
||||
try {
|
||||
const messages = await fetchSessionMessages(session.id, directory)
|
||||
// Equal timestamps belong to the reverted side of the boundary. Keeping
|
||||
// them would rely on unrelated message IDs to decide chronology.
|
||||
const target = firstUserMessageAtOrAfter(messages, cutoff)
|
||||
if (!target) continue
|
||||
const reverted = await opencodeClient.revertSession(session.id, target.id, undefined, directory)
|
||||
mirrorSessionIntoLiveStores(reverted, directory)
|
||||
} catch (error) {
|
||||
console.error(`[session-actions] Failed to cascade revert to descendant ${session.id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cascadeUnrevertToDescendants(rootId: string): Promise<void> {
|
||||
for (const { session, directory } of getDescendantSessions(rootId)) {
|
||||
if (!session.revert) continue
|
||||
try {
|
||||
const result = await sdk().session.unrevert({ sessionID: session.id, directory })
|
||||
mirrorSessionIntoLiveStores(assertSdkData(result, "session.unrevert"), directory)
|
||||
} catch (error) {
|
||||
console.error(`[session-actions] Failed to cascade unrevert to descendant ${session.id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getGlobalSessionSnapshot(sessionId: string): Session | null {
|
||||
const global = useGlobalSessionsStore.getState()
|
||||
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
|
||||
@@ -1842,6 +1912,11 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
const { store, directory } = dirStoreForSession(sessionId)
|
||||
const state = store.getState()
|
||||
|
||||
const localTarget = state.message[sessionId]?.find((message) => message.id === messageId)
|
||||
const targetMessage = localTarget
|
||||
?? (await fetchSessionMessages(sessionId, directory)).find((message) => message.id === messageId)
|
||||
if (!targetMessage) throw new Error(`Cannot revert session: message ${messageId} was not found`)
|
||||
|
||||
// Abort if busy before mutating session state
|
||||
const status = state.session_status[sessionId]
|
||||
if (status && status.type !== "idle") {
|
||||
@@ -1912,6 +1987,9 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
|
||||
|
||||
// Call SDK and merge authoritative result into store
|
||||
try {
|
||||
// Descendants go first because OpenCode also restores file snapshots during
|
||||
// revert. All sessions share a directory, so the parent's snapshot must win.
|
||||
await cascadeRevertToDescendants(sessionId, targetMessage.time.created)
|
||||
const revertedSession = await opencodeClient.revertSession(sessionId, messageId, undefined, directory)
|
||||
const current = store.getState()
|
||||
const updated = [...current.session]
|
||||
@@ -1994,6 +2072,9 @@ export async function unrevertSession(sessionId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Descendants go first because unrevert can also restore shared file state.
|
||||
// Applying the parent last leaves the working tree at the parent's snapshot.
|
||||
await cascadeUnrevertToDescendants(sessionId)
|
||||
const result = await sdk().session.unrevert({ sessionID: sessionId, directory })
|
||||
const unrevertedSession = assertSdkData(result, "session.unrevert")
|
||||
const current = store.getState()
|
||||
|
||||
Reference in New Issue
Block a user