fix(ui): reconcile git state after worktree changes

This commit is contained in:
Iuliia Ivashko
2026-09-04 16:41:39 +03:00
parent bde6fed8a2
commit 94c90a16b6
20 changed files with 438 additions and 165 deletions
@@ -67,6 +67,7 @@ mock.module("@/components/ui", () => ({
import { INITIAL_STATE, type State } from "../types"
import { ChildStoreManager, type DirectoryStore } from "../child-store"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { sessionEvents } from "@/lib/sessionEvents"
const {
createEventRoutingIndex,
handleEvent,
@@ -340,4 +341,40 @@ describe("resyncBlockingRequestsForDirectory", () => {
unsubscribe()
childStores.disposeAll()
})
test("refreshes Git once when a live mutating tool completes between renders", () => {
const childStores = new ChildStoreManager()
childStores.ensureChild("/repo", { bootstrap: false })
const routingIndex = createEventRoutingIndex()
const refreshes: Array<{ directory: string; paths?: string[] }> = []
const unsubscribe = sessionEvents.onGitRefreshHint((hint) => refreshes.push(hint))
// SAFETY: this fixture supplies the SDK event discriminator and the tool
// part identity, tool name, and state fields consumed by the reducer.
const toolEvent = (tool: string, status: "pending" | "completed" | "error") => ({
type: "message.part.updated",
properties: {
part: {
id: "prt_tool",
messageID: "msg_assistant",
sessionID: "ses_a",
type: "tool",
tool,
state: { status, input: {}, metadata: {} },
},
},
}) as Event
try {
handleEvent("/repo", toolEvent("apply_patch", "pending"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("read", "completed"), childStores, routingIndex, getRuntimeKey())
handleEvent("/repo", toolEvent("edit", "error"), childStores, routingIndex, getRuntimeKey())
expect(refreshes).toEqual([{ directory: "/repo" }])
} finally {
unsubscribe()
childStores.disposeAll()
}
})
})
+9
View File
@@ -78,6 +78,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { isFilesystemError } from "@/lib/api/files-errors"
import { formatMessage, useI18nStore } from "@/lib/i18n"
import { sessionEvents } from "@/lib/sessionEvents"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
@@ -1818,6 +1819,10 @@ export function handleEvent(
// type will mutate. This preserves reference identity for untouched slices
// so Zustand selectors skip re-renders for unrelated subscribers.
const current = getDirectoryEventState(store, batch)
const updatedPart = payload.type === "message.part.updated" ? payload.properties.part : undefined
const previousPart = updatedPart && "messageID" in updatedPart
? current.part[updatedPart.messageID]?.find((part) => part.id === updatedPart.id)
: undefined
const draft: State = { ...current }
const clonedFields = batch?.clonedFields.get(store) ?? new Set<keyof State>()
const newlyClonedFields: Array<keyof State> = []
@@ -1894,6 +1899,10 @@ export function handleEvent(
const reducerChanged = typeof reducerResult === "boolean" ? reducerResult : reducerResult.changed
const materializationResult = typeof reducerResult === "boolean" ? undefined : reducerResult.materialization
if (reducerChanged && updatedPart) {
sessionEvents.requestGitRefreshForToolTransition(resolvedDirectory, previousPart, updatedPart)
}
if (reducerChanged) {
countSyncPerformance("reducerChangedEvents")
const eventSessionID = getSessionIdFromPayload(payload) ?? undefined