diff --git a/CHANGELOG.md b/CHANGELOG.md index 471d9753..b5568e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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: 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. - 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. diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index eb68caa6..c5fbb833 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -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. 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. +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`: diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 758d99ed..f48d4f0d 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -13,6 +13,9 @@ let permissionReplyError: unknown | null = null let sessionShareResult: { 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: [] } +const sessionMessageRecords = new Map>() +const failingRevertSessionIds = new Set() +const failingUnrevertSessionIds = new Set() let sessionDeleteError: unknown | null = null let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null @@ -68,6 +71,13 @@ const mockSdk = { replyCalls.push({ method: "session.revert", params }) return Promise.resolve(sessionRevertResult) }), + unrevert: mock((params: Record) => { + 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) => { replyCalls.push({ method: "session.abort", params }) return Promise.resolve({ data: true }) @@ -127,6 +137,10 @@ mock.module("@/lib/opencode/client", () => ({ getDirectory: () => "/test/project", getFilesystemHome: mock(async () => "/home/test"), 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 }) => { replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } }) return Promise.resolve(true) @@ -140,11 +154,11 @@ mock.module("@/lib/opencode/client", () => ({ method: "session.revert", params: { sessionID: sessionId, messageID: messageId, partID: partId, directory }, }) - if (sessionRevertResult.error) { + if (sessionRevertResult.error || failingRevertSessionIds.has(sessionId)) { const status = sessionRevertResult.response?.status 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, directory?: string | null) => { replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } }) @@ -1293,6 +1307,8 @@ describe("revertToMessage passes session directory", () => { replyCalls.length = 0 scopedClientDirectories.length = 0 sessionRevertResult = {} + sessionMessageRecords.clear() + failingRevertSessionIds.clear() Object.assign(inputState, { pendingInputText: "previous draft", 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(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", () => { diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index fa56cc25..3651534c 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -438,6 +438,76 @@ type SessionListSnapshot = { type DirectoryStoreApi = ReturnType +type DescendantSession = { + session: Session + directory: string +} + +function getDescendantSessions(rootId: string): DescendantSession[] { + const stores = _childStores + if (!stores) return [] + + const sessionsById = new Map() + 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 { + const records = await opencodeClient.getSessionMessages(sessionId, undefined, directory) + return records.map(({ info }) => info) +} + +async function cascadeRevertToDescendants(rootId: string, cutoff: number): Promise { + 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 { + 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 { } } + // 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()