Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/src/context/directory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const sessionFields = new Set([
"session_status",
"session_working",
"session_diff",
"diff_version",
"todo",
"permission",
"question",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/context/global-sync/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function directoryState() {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
diff_version: {},
todo: {},
permission: {},
question: {},
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/context/global-sync/child-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export function createChildStoreManager(input: {
return (type ?? "idle") !== "idle"
},
session_diff: {},
diff_version: {},
todo: {},
permission: {},
question: {},
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/context/global-sync/event-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const baseState = (input: Partial<State> = {}) =>
sessionTotal: 0,
session_status: {},
session_diff: {},
diff_version: {},
todo: {},
permission: {},
question: {},
Expand Down
10 changes: 10 additions & 0 deletions packages/app/src/context/global-sync/event-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"

const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch"])
const SESSION_CONTENT_EVENTS = new Set([
"session.diff",
"todo.updated",
Expand Down Expand Up @@ -312,6 +313,15 @@ export function applyDirectoryEvent(input: {
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
// Bump diff_version when a file-editing tool completes so the diff query refetches mid-turn
if (
part.type === "tool" &&
EDIT_TOOLS.has(part.tool) &&
part.state.status === "completed" &&
(part.state as { metadata?: Record<string, unknown> }).metadata?.filediff
) {
input.setStore("diff_version", part.sessionID, (v) => (v ?? 0) + 1)
}
input.setStore(
produce((draft) => {
delete draft.part_text_accum_delta[part.id]
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/context/global-sync/session-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe("app session cache", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
diff_version: Record<string, number | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, never[] | undefined>
Expand All @@ -37,6 +38,7 @@ describe("app session cache", () => {
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
diff_version: { ses_1: 0 },
todo: { ses_1: [] as Todo[] },
message: {},
session_message: {},
Expand All @@ -63,6 +65,7 @@ describe("app session cache", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
diff_version: Record<string, number | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, never[] | undefined>
Expand All @@ -73,6 +76,7 @@ describe("app session cache", () => {
} = {
session_status: {},
session_diff: {},
diff_version: {},
todo: {},
message: { ses_1: [m] },
session_message: {},
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/context/global-sync/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
diff_version: Record<string, number | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
session_message: Record<string, SessionMessageInfo[] | undefined>
Expand Down Expand Up @@ -34,6 +35,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
delete store.todo[sessionID]
delete store.session_message[sessionID]
delete store.session_diff[sessionID]
delete store.diff_version[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/context/global-sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export type State = {
session_diff: {
[sessionID: string]: FileDiffInfo[]
}
diff_version: {
[sessionID: string]: number
}
todo: {
[sessionID: string]: Todo[]
}
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/context/server-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export function createServerSession(
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>,
diff_version: {} as Record<string, number>,
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -668,9 +668,12 @@ export default function Page() {
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch"])
// Refetch when the session transitions to idle (assistant finished, snapshot taken)
// or when a file-editing tool completes mid-turn (diff_version bumps)
const sessionDiffVersion = () => {
const id = params.id
return id ? sync().data.session_status[id]?.type ?? "idle" : "idle"
const status = id ? sync().data.session_status[id]?.type ?? "idle" : "idle"
const version = id ? sync().data.diff_version[id] ?? 0 : 0
return `${status}:${version}`
}
const sessionDiffKey = () => ["session-diff", params.id ?? "", sessionDiffVersion()] as const
const sessionDiffQuery = createQuery(() => {
Expand Down
93 changes: 61 additions & 32 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,9 @@ const layer: Layer.Layer<
const all = yield* messages({ sessionID }).pipe(Effect.orDie)
if (!all.length) return [] as Snapshot.FileDiff[]

const session = yield* get(sessionID).pipe(Effect.orDie)
const sessionStartTime = session.time.created

// Find the first step-start snapshot hash (session-start ref)
// and the last step-finish snapshot hash (session-end ref)
let from: string | undefined
Expand Down Expand Up @@ -870,44 +873,70 @@ const layer: Layer.Layer<

// If we have snapshot hashes and agent-touched files, compute the real diff
if (from && agentFilesAbsolute.size > 0) {
// Use last step-finish hash if available (completed session),
// otherwise fall back to current working tree state (session still running)
const to = lastStepFinish ?? (yield* snapshot.track())
// Always track the live working tree so in-progress edits are included;
// fall back to the last step-finish hash only when tracking is unavailable.
const to = (yield* snapshot.track()) ?? lastStepFinish
if (to) {
// Normalize agent-touched files to relative paths (diffFull returns relative paths)
// Normalize and split files into in-worktree (relative) and external (absolute)
const ctx = yield* InstanceState.context
const worktree = ctx.worktree
const agentFiles = new Set<string>()
for (const abs of agentFilesAbsolute) {
const rel = abs.startsWith(worktree)
? abs.slice(worktree.length).replace(/^\//, "").replaceAll("\\", "/")
: abs.replaceAll("\\", "/")
agentFiles.add(rel)
const externalFiles: string[] = []
for (const raw of agentFilesAbsolute) {
// Resolve relative paths (e.g. ../../../other-repo/file.ts) to absolute
const abs = raw.startsWith("/") ? raw : path.resolve(worktree, raw)
if (abs.startsWith(worktree + "/") || abs === worktree) {
const rel = abs.slice(worktree.length + 1).replaceAll("\\", "/")
agentFiles.add(rel)
} else {
externalFiles.push(abs)
}
}

// Primary path: diff in-worktree files via snapshot
let results: Snapshot.FileDiff[] = []
if (agentFiles.size > 0) {
const allDiffs = yield* snapshot.diffFull(from, to).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logWarning("diffFull failed, falling through to per-file fallback", { cause })
return [] as Snapshot.FileDiff[]
}),
),
)
const filtered = allDiffs.filter(
(d: Snapshot.FileDiff) => d.file && agentFiles.has(d.file) && (d.additions ?? 0) + (d.deletions ?? 0) > 0,
)
if (filtered.length > 0) {
results = filtered
} else {
// Fallback A: per-file git show vs current disk for in-worktree files
const perFileDiffs = yield* snapshot.diffFromDisk(from, [...agentFiles]).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logWarning("diffFromDisk failed, falling through to summary fallback", { cause })
return [] as Snapshot.FileDiff[]
}),
),
)
if (perFileDiffs.length > 0) results = perFileDiffs
}
}

// External files: diff against HEAD in their own git repos
if (externalFiles.length > 0) {
const extDiffs = yield* snapshot.diffExternalFiles(externalFiles, sessionStartTime).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logWarning("diffExternalFiles failed", { cause })
return [] as Snapshot.FileDiff[]
}),
),
)
results = [...results, ...extDiffs]
}

const allDiffs = yield* snapshot.diffFull(from, to).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logWarning("diffFull failed, falling through to per-file fallback", { cause })
return [] as Snapshot.FileDiff[]
}),
),
)
const filtered = allDiffs.filter(
(d: Snapshot.FileDiff) => d.file && agentFiles.has(d.file) && (d.additions ?? 0) + (d.deletions ?? 0) > 0,
)
if (filtered.length > 0) return filtered

// Fallback A: primary returned empty but from hash exists — per-file git show vs current disk
const perFileDiffs = yield* snapshot.diffFromDisk(from, [...agentFiles]).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logWarning("diffFromDisk failed, falling through to summary fallback", { cause })
return [] as Snapshot.FileDiff[]
}),
),
)
if (perFileDiffs.length > 0) return perFileDiffs
if (results.length > 0) return results
}
}

Expand Down
91 changes: 90 additions & 1 deletion packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface Interface {
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<FileDiff[]>
readonly diffFromDisk: (ref: string, files: string[]) => Effect.Effect<FileDiff[]>
readonly diffExternalFiles: (files: string[], startTime?: number) => Effect.Effect<FileDiff[]>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
Expand Down Expand Up @@ -801,14 +802,99 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
)
})

const diffExternalFiles = Effect.fnUntraced(function* (files: string[], startTime?: number) {
const result: FileDiff[] = []
const patchFn = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))

// Group files by their git root to avoid repeated rev-parse calls
const repoRoots = new Map<string, { root: string; files: { abs: string; rel: string }[] }>()

for (const absPath of files) {
const dir = path.dirname(absPath)
const topLevel = yield* appProcess
.run(ChildProcess.make("git", ["rev-parse", "--show-toplevel"], { cwd: dir, extendEnv: true }), {})
.pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: Buffer.from(""), stderr: Buffer.from("") })))
if (topLevel.exitCode !== 0) continue
const repoRoot = topLevel.stdout.toString("utf8").trim()
if (!repoRoot) continue

const relPath = absPath.startsWith(repoRoot + "/")
? absPath.slice(repoRoot.length + 1)
: absPath.startsWith(repoRoot + "\\")
? absPath.slice(repoRoot.length + 1).replaceAll("\\", "/")
: path.relative(repoRoot, absPath).replaceAll("\\", "/")

const entry = repoRoots.get(repoRoot) ?? { root: repoRoot, files: [] }
entry.files.push({ abs: absPath, rel: relPath })
repoRoots.set(repoRoot, entry)
}

for (const [, { root, files: repoFiles }] of repoRoots) {
// Find the commit that was HEAD at session start time
let ref = "HEAD"
if (startTime) {
const isoTime = new Date(startTime).toISOString()
const revList = yield* appProcess
.run(
ChildProcess.make("git", ["-C", root, "rev-list", "-1", `--before=${isoTime}`, "HEAD"], {
extendEnv: true,
}),
{},
)
.pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: Buffer.from(""), stderr: Buffer.from("") })))
const foundRef = revList.exitCode === 0 ? revList.stdout.toString("utf8").trim() : ""
if (foundRef) ref = foundRef
}

for (const { abs: absPath, rel: relPath } of repoFiles) {
// Get the file content at the session-start ref
const showResult = yield* appProcess
.run(ChildProcess.make("git", ["-C", root, "show", `${ref}:${relPath}`], { extendEnv: true }), {})
.pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: Buffer.from(""), stderr: Buffer.from("") })))
const before = showResult.exitCode === 0 ? showResult.stdout.toString("utf8") : ""

// Read current file from disk
const after = yield* fs.readFileString(absPath).pipe(Effect.catch(() => Effect.succeed("")))

if (before === after) continue

// Compute line-level additions/deletions
const sp = structuredPatch(relPath, relPath, before, after, "", "")
let adds = 0
let dels = 0
for (const hunk of sp.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("+")) adds++
else if (line.startsWith("-")) dels++
}
}

const status: "added" | "deleted" | "modified" = before === "" ? "added" : after === "" ? "deleted" : "modified"
// Use absolute path so the client doesn't prefix it with the wrong workspace
const home = process.env.HOME
const displayPath = home && absPath.startsWith(home + "/") ? "~" + absPath.slice(home.length) : absPath
result.push({
file: displayPath,
patch: patchFn(relPath, before, after),
additions: adds,
deletions: dels,
status,
})
}
}

return result
})

yield* cleanup().pipe(
Effect.catchCause((cause) => Effect.logError("cleanup loop failed", { cause: Cause.pretty(cause) })),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)

return { cleanup, track, patch, restore, revert, diff, diffFull, diffFromDisk }
return { cleanup, track, patch, restore, revert, diff, diffFull, diffFromDisk, diffExternalFiles }
}),
)

Expand Down Expand Up @@ -840,6 +926,9 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | C
diffFromDisk: Effect.fn("Snapshot.diffFromDisk")(function* (ref: string, files: string[]) {
return yield* InstanceState.useEffect(state, (s) => s.diffFromDisk(ref, files))
}),
diffExternalFiles: Effect.fn("Snapshot.diffExternalFiles")(function* (files: string[], startTime?: number) {
return yield* InstanceState.useEffect(state, (s) => s.diffExternalFiles(files, startTime))
}),
})
}),
)
Expand Down
Loading