fix(app): diff list normalization
This commit is contained in:
parent
d98be39344
commit
689b1a4b3a
6 changed files with 159 additions and 15 deletions
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
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 SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
|
|
@ -162,7 +163,7 @@ export function applyDirectoryEvent(input: {
|
||||||
}
|
}
|
||||||
case "session.diff": {
|
case "session.diff": {
|
||||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||||
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
|
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "todo.updated": {
|
case "todo.updated": {
|
||||||
|
|
@ -177,7 +178,7 @@ export function applyDirectoryEvent(input: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = (event.properties as { info: Message }).info
|
const info = clean((event.properties as { info: Message }).info)
|
||||||
const messages = input.store.message[info.sessionID]
|
const messages = input.store.message[info.sessionID]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
input.setStore("message", info.sessionID, [info])
|
input.setStore("message", info.sessionID, [info])
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { useGlobalSync } from "./global-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
|
|
@ -300,7 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
||||||
)
|
)
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
|
||||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||||
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||||
return {
|
return {
|
||||||
|
|
@ -509,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
return runInflight(inflightDiff, key, () =>
|
return runInflight(inflightDiff, key, () =>
|
||||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||||
if (!tracked(directory, sessionID)) return
|
if (!tracked(directory, sessionID)) return
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
|
import { diffs as list } from "@/utils/diffs"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
|
|
@ -430,7 +431,7 @@ export default function Page() {
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
||||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||||
const canReview = createMemo(() => !!sync.project)
|
const canReview = createMemo(() => !!sync.project)
|
||||||
|
|
@ -611,7 +612,7 @@ export default function Page() {
|
||||||
.diff({ mode })
|
.diff({ mode })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (vcsRun.get(mode) !== run) return
|
if (vcsRun.get(mode) !== run) return
|
||||||
setVcs("diff", mode, result.data ?? [])
|
setVcs("diff", mode, list(result.data))
|
||||||
setVcs("ready", mode, true)
|
setVcs("ready", mode, true)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
@ -649,7 +650,7 @@ export default function Page() {
|
||||||
return open
|
return open
|
||||||
}, desktopReviewOpen())
|
}, desktopReviewOpen())
|
||||||
|
|
||||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
|
||||||
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
|
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
|
||||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||||
const list: ChangeMode[] = []
|
const list: ChangeMode[] = []
|
||||||
|
|
@ -669,15 +670,11 @@ export default function Page() {
|
||||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||||
})
|
})
|
||||||
const reviewDiffs = createMemo(() => {
|
const reviewDiffs = createMemo(() => {
|
||||||
if (store.changes === "git") return vcs.diff.git
|
if (store.changes === "git") return list(vcs.diff.git)
|
||||||
if (store.changes === "branch") return vcs.diff.branch
|
if (store.changes === "branch") return list(vcs.diff.branch)
|
||||||
return turnDiffs()
|
return turnDiffs()
|
||||||
})
|
})
|
||||||
const reviewCount = createMemo(() => {
|
const reviewCount = createMemo(() => reviewDiffs().length)
|
||||||
if (store.changes === "git") return vcs.diff.git.length
|
|
||||||
if (store.changes === "branch") return vcs.diff.branch.length
|
|
||||||
return turnDiffs().length
|
|
||||||
})
|
|
||||||
const hasReview = createMemo(() => reviewCount() > 0)
|
const hasReview = createMemo(() => reviewCount() > 0)
|
||||||
const reviewReady = createMemo(() => {
|
const reviewReady = createMemo(() => {
|
||||||
if (store.changes === "git") return vcs.ready.git
|
if (store.changes === "git") return vcs.ready.git
|
||||||
|
|
|
||||||
74
packages/app/src/utils/diffs.test.ts
Normal file
74
packages/app/src/utils/diffs.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { diffs, message } from "./diffs"
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
file: "src/app.ts",
|
||||||
|
patch: "@@ -1 +1 @@\n-old\n+new\n",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
status: "modified",
|
||||||
|
} satisfies SnapshotFileDiff
|
||||||
|
|
||||||
|
describe("diffs", () => {
|
||||||
|
test("keeps valid arrays", () => {
|
||||||
|
expect(diffs([item])).toEqual([item])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("wraps a single diff object", () => {
|
||||||
|
expect(diffs(item)).toEqual([item])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads keyed diff objects", () => {
|
||||||
|
expect(diffs({ a: item })).toEqual([item])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drops invalid entries", () => {
|
||||||
|
expect(
|
||||||
|
diffs([
|
||||||
|
item,
|
||||||
|
{ file: "src/bad.ts", additions: 1, deletions: 1 },
|
||||||
|
{ patch: item.patch, additions: 1, deletions: 1 },
|
||||||
|
]),
|
||||||
|
).toEqual([item])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("message", () => {
|
||||||
|
test("normalizes user summaries with object diffs", () => {
|
||||||
|
const input = {
|
||||||
|
id: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
summary: {
|
||||||
|
title: "Edit",
|
||||||
|
diffs: { a: item },
|
||||||
|
},
|
||||||
|
} as unknown as Message
|
||||||
|
|
||||||
|
expect(message(input)).toMatchObject({
|
||||||
|
summary: {
|
||||||
|
title: "Edit",
|
||||||
|
diffs: [item],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drops invalid user summaries", () => {
|
||||||
|
const input = {
|
||||||
|
id: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
summary: true,
|
||||||
|
} as unknown as Message
|
||||||
|
|
||||||
|
expect(message(input)).toMatchObject({ summary: undefined })
|
||||||
|
})
|
||||||
|
})
|
||||||
49
packages/app/src/utils/diffs.ts
Normal file
49
packages/app/src/utils/diffs.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
type Diff = SnapshotFileDiff | VcsFileDiff
|
||||||
|
|
||||||
|
function diff(value: unknown): value is Diff {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||||
|
if (!("file" in value) || typeof value.file !== "string") return false
|
||||||
|
if (!("patch" in value) || typeof value.patch !== "string") return false
|
||||||
|
if (!("additions" in value) || typeof value.additions !== "number") return false
|
||||||
|
if (!("deletions" in value) || typeof value.deletions !== "number") return false
|
||||||
|
if (!("status" in value) || value.status === undefined) return true
|
||||||
|
return value.status === "added" || value.status === "deleted" || value.status === "modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffs(value: unknown): Diff[] {
|
||||||
|
if (Array.isArray(value) && value.every(diff)) return value
|
||||||
|
if (Array.isArray(value)) return value.filter(diff)
|
||||||
|
if (diff(value)) return [value]
|
||||||
|
if (!object(value)) return []
|
||||||
|
return Object.values(value).filter(diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function message(value: Message): Message {
|
||||||
|
if (value.role !== "user") return value
|
||||||
|
|
||||||
|
const raw = value.summary as unknown
|
||||||
|
if (raw === undefined) return value
|
||||||
|
if (!object(raw)) return { ...value, summary: undefined }
|
||||||
|
|
||||||
|
const title = typeof raw.title === "string" ? raw.title : undefined
|
||||||
|
const body = typeof raw.body === "string" ? raw.body : undefined
|
||||||
|
const next = diffs(raw.diffs)
|
||||||
|
|
||||||
|
if (title === raw.title && body === raw.body && next === raw.diffs) return value
|
||||||
|
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
summary: {
|
||||||
|
...(title === undefined ? {} : { title }),
|
||||||
|
...(body === undefined ? {} : { body }),
|
||||||
|
diffs: next,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,26 @@ export type SessionReviewFocus = { file: string; id: string }
|
||||||
type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult<any> }
|
type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult<any> }
|
||||||
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
|
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
|
||||||
|
|
||||||
|
function diff(value: unknown): value is ReviewDiff {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||||
|
if (!("file" in value) || typeof value.file !== "string") return false
|
||||||
|
if (!("additions" in value) || typeof value.additions !== "number") return false
|
||||||
|
if (!("deletions" in value) || typeof value.deletions !== "number") return false
|
||||||
|
if ("patch" in value && value.patch !== undefined && typeof value.patch !== "string") return false
|
||||||
|
if ("before" in value && value.before !== undefined && typeof value.before !== "string") return false
|
||||||
|
if ("after" in value && value.after !== undefined && typeof value.after !== "string") return false
|
||||||
|
if (!("status" in value) || value.status === undefined) return true
|
||||||
|
return value.status === "added" || value.status === "deleted" || value.status === "modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(value: unknown): ReviewDiff[] {
|
||||||
|
if (Array.isArray(value) && value.every(diff)) return value
|
||||||
|
if (Array.isArray(value)) return value.filter(diff)
|
||||||
|
if (diff(value)) return [value]
|
||||||
|
if (!value || typeof value !== "object") return []
|
||||||
|
return Object.values(value).filter(diff)
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionReviewProps {
|
export interface SessionReviewProps {
|
||||||
title?: JSX.Element
|
title?: JSX.Element
|
||||||
empty?: JSX.Element
|
empty?: JSX.Element
|
||||||
|
|
@ -157,7 +177,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
const opened = () => store.opened
|
const opened = () => store.opened
|
||||||
|
|
||||||
const open = () => props.open ?? store.open
|
const open = () => props.open ?? store.open
|
||||||
const items = createMemo<Item[]>(() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })))
|
const items = createMemo<Item[]>(() =>
|
||||||
|
list(props.diffs).map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })),
|
||||||
|
)
|
||||||
const files = createMemo(() => items().map((diff) => diff.file))
|
const files = createMemo(() => items().map((diff) => diff.file))
|
||||||
const grouped = createMemo(() => {
|
const grouped = createMemo(() => {
|
||||||
const next = new Map<string, SessionReviewComment[]>()
|
const next = new Map<string, SessionReviewComment[]>()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue