Merge pull request #3146 from alexandrereyes/feat/undo-subagent

feat(ui): cascade undo and redo to subagents
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 01:23:46 +03:00
committed by GitHub
4 changed files with 216 additions and 2 deletions
+1
View File
@@ -27,6 +27,7 @@ All notable changes to this project will be documented in this file.
- Search: every searchable picker uses one matcher now — best matches first, multi-word queries in any order, punctuation ignored ("gpt4o" finds "gpt-4o"). Ctrl/Cmd+P matches whole file paths. - Search: every searchable picker uses one matcher now — best matches first, multi-word queries in any order, punctuation ignored ("gpt4o" finds "gpt-4o"). Ctrl/Cmd+P matches whole file paths.
- Chat: @ file mentions rank files and directories together by match quality, and long paths keep the folder next to the file name visible. - Chat: @ file mentions rank files and directories together by match quality, and long paths keep the folder next to the file name visible.
- Chat: a "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns automatic following off entirely; with it off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area. - Chat: a "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns automatic following off entirely; with it off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area.
- Chat: undoing or redoing a parent session now keeps its subagent sessions at the same point in history instead of leaving their later work behind (thanks to @alexandrereyes).
- Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme) are found by typing but stay off the first screen. - Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme) are found by typing but stay off the first screen.
- Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone. - Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone.
- Browser: an agent opening a page with the browser tool no longer pops the browser panel open (or switches the surface you're on) — the page loads in the background and the rail is where you peek at it. - Browser: an agent opening a page with the browser tool no longer pops the browser panel open (or switches the surface you're on) — the page loads in the background and the rail is where you peek at it.
+1
View File
@@ -268,6 +268,7 @@ Rules:
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. 6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation. 7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. 8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
9. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative.
Examples of global-store updates performed in `session-actions.ts`: Examples of global-store updates performed in `session-actions.ts`:
+133 -2
View File
@@ -13,6 +13,9 @@ let permissionReplyError: unknown | null = null
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] } let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
const sessionMessageRecords = new Map<string, Array<{ info: Message; parts: Part[] }>>()
const failingRevertSessionIds = new Set<string>()
const failingUnrevertSessionIds = new Set<string>()
let sessionDeleteError: unknown | null = null let sessionDeleteError: unknown | null = null
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
@@ -68,6 +71,13 @@ const mockSdk = {
replyCalls.push({ method: "session.revert", params }) replyCalls.push({ method: "session.revert", params })
return Promise.resolve(sessionRevertResult) return Promise.resolve(sessionRevertResult)
}), }),
unrevert: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.unrevert", params })
if (failingUnrevertSessionIds.has(String(params.sessionID))) {
return Promise.resolve({ error: { message: "rejected" }, response: { status: 500 } })
}
return Promise.resolve({ data: { id: params.sessionID, time: { created: 1 } } })
}),
abort: mock((params: Record<string, unknown>) => { abort: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.abort", params }) replyCalls.push({ method: "session.abort", params })
return Promise.resolve({ data: true }) return Promise.resolve({ data: true })
@@ -127,6 +137,10 @@ mock.module("@/lib/opencode/client", () => ({
getDirectory: () => "/test/project", getDirectory: () => "/test/project",
getFilesystemHome: mock(async () => "/home/test"), getFilesystemHome: mock(async () => "/home/test"),
getSdkClient: () => mockSdk, getSdkClient: () => mockSdk,
getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => {
replyCalls.push({ method: "session.messages", params: { sessionID: sessionId, directory } })
return Promise.resolve(sessionMessageRecords.get(sessionId) ?? [])
}),
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => { replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } }) replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
return Promise.resolve(true) return Promise.resolve(true)
@@ -140,11 +154,11 @@ mock.module("@/lib/opencode/client", () => ({
method: "session.revert", method: "session.revert",
params: { sessionID: sessionId, messageID: messageId, partID: partId, directory }, params: { sessionID: sessionId, messageID: messageId, partID: partId, directory },
}) })
if (sessionRevertResult.error) { if (sessionRevertResult.error || failingRevertSessionIds.has(sessionId)) {
const status = sessionRevertResult.response?.status const status = sessionRevertResult.response?.status
throw new Error(`session.revert failed${status ? ` (${status})` : ""}: rejected`) throw new Error(`session.revert failed${status ? ` (${status})` : ""}: rejected`)
} }
return Promise.resolve(sessionRevertResult.data) return Promise.resolve(sessionRevertResult.data ?? { id: sessionId, time: { created: 1 }, revert: { messageID: messageId } })
}), }),
updateSession: mock((sessionId: string, changes: Record<string, unknown>, directory?: string | null) => { updateSession: mock((sessionId: string, changes: Record<string, unknown>, directory?: string | null) => {
replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } }) replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } })
@@ -1293,6 +1307,8 @@ describe("revertToMessage passes session directory", () => {
replyCalls.length = 0 replyCalls.length = 0
scopedClientDirectories.length = 0 scopedClientDirectories.length = 0
sessionRevertResult = {} sessionRevertResult = {}
sessionMessageRecords.clear()
failingRevertSessionIds.clear()
Object.assign(inputState, { Object.assign(inputState, {
pendingInputText: "previous draft", pendingInputText: "previous draft",
pendingInputMode: "normal" as const, pendingInputMode: "normal" as const,
@@ -1354,6 +1370,121 @@ describe("revertToMessage passes session directory", () => {
expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert).toBe(undefined) expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert).toBe(undefined)
expect(inputState.pendingInputText).toBe("previous draft") expect(inputState.pendingInputText).toBe("previous draft")
}) })
test("reverts recursive descendants at their first user message on or after the parent cutoff", async () => {
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 } },
{ id: "child", parentID: "root", directory: "/tree", time: { created: 2 } },
{ id: "grandchild", parentID: "child", directory: "/tree", time: { created: 3 } },
{ id: "old-child", parentID: "root", directory: "/tree", time: { created: 4 } },
] as Session[]
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
sessionMessageRecords.set("child", [
{ info: { id: "child-before", sessionID: "child", role: "user", time: { created: 10 } } as Message, parts: [] },
{ info: { id: "child-boundary", sessionID: "child", role: "user", time: { created: 20 } } as Message, parts: [] },
{ info: { id: "child-later", sessionID: "child", role: "user", time: { created: 30 } } as Message, parts: [] },
])
sessionMessageRecords.set("grandchild", [
{ info: { id: "grandchild-assistant", sessionID: "grandchild", role: "assistant", time: { created: 20 } } as Message, parts: [] },
{ info: { id: "grandchild-user", sessionID: "grandchild", role: "user", time: { created: 21 } } as Message, parts: [] },
])
sessionMessageRecords.set("old-child", [
{ info: { id: "old-child-user", sessionID: "old-child", role: "user", time: { created: 19 } } as Message, parts: [] },
])
const { setActionRefs, revertToMessage } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await revertToMessage("root", "root-cutoff")
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => [
call.params.sessionID,
call.params.messageID,
])).toEqual([
["child", "child-boundary"],
["grandchild", "grandchild-user"],
["root", "root-cutoff"],
])
})
test("continues reverting other descendants and the parent when one child fails", async () => {
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 } },
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 } },
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 } },
] as Session[]
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
for (const id of ["failing-child", "healthy-child"]) {
sessionMessageRecords.set(id, [{
info: { id: `${id}-target`, sessionID: id, role: "user", time: { created: 20 } } as Message,
parts: [],
}])
}
failingRevertSessionIds.add("failing-child")
const { setActionRefs, revertToMessage } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await revertToMessage("root", "root-cutoff")
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => call.params.sessionID)).toEqual([
"failing-child",
"healthy-child",
"root",
])
})
})
describe("unrevertSession descendant cascade", () => {
beforeEach(() => {
replyCalls.length = 0
sessionMessagesResult = { data: [] }
failingUnrevertSessionIds.clear()
})
test("unreverts only marked descendants before the parent", async () => {
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
{ id: "marked-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "child-target" } },
{ id: "plain-child", parentID: "root", directory: "/tree", time: { created: 3 } },
{ id: "marked-grandchild", parentID: "plain-child", directory: "/tree", time: { created: 4 }, revert: { messageID: "grandchild-target" } },
] as Session[]
const store = createStore({}, { session: sessions })
const { setActionRefs, unrevertSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await unrevertSession("root")
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
"marked-child",
"marked-grandchild",
"root",
])
})
test("continues after a descendant unrevert fails", async () => {
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "first-target" } },
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "second-target" } },
] as Session[]
const store = createStore({}, { session: sessions })
failingUnrevertSessionIds.add("failing-child")
const { setActionRefs, unrevertSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await unrevertSession("root")
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
"failing-child",
"healthy-child",
"root",
])
})
}) })
describe("dismissPermission passes directory", () => { describe("dismissPermission passes directory", () => {
+81
View File
@@ -438,6 +438,76 @@ type SessionListSnapshot = {
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]> 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 { function getGlobalSessionSnapshot(sessionId: string): Session | null {
const global = useGlobalSessionsStore.getState() const global = useGlobalSessionsStore.getState()
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null 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 { store, directory } = dirStoreForSession(sessionId)
const state = store.getState() 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 // Abort if busy before mutating session state
const status = state.session_status[sessionId] const status = state.session_status[sessionId]
if (status && status.type !== "idle") { 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 // Call SDK and merge authoritative result into store
try { 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 revertedSession = await opencodeClient.revertSession(sessionId, messageId, undefined, directory)
const current = store.getState() const current = store.getState()
const updated = [...current.session] 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 result = await sdk().session.unrevert({ sessionID: sessionId, directory })
const unrevertedSession = assertSdkData(result, "session.unrevert") const unrevertedSession = assertSdkData(result, "session.unrevert")
const current = store.getState() const current = store.getState()