From fbb074480d526b14e88fe719311a918cec3dc906 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 17 Mar 2026 19:43:24 +0530 Subject: [PATCH 01/12] feat(opencode): add vcs diff api --- packages/opencode/src/project/vcs.ts | 252 ++++++++++++++++++++- packages/opencode/src/server/server.ts | 38 +++- packages/opencode/test/project/vcs.test.ts | 76 +++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 33 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 23 +- 5 files changed, 408 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 4d1f7b766b18..12dc961ca4e3 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,11 +6,228 @@ import { Instance } from "./instance" import { InstanceContext } from "@/effect/instance-context" import { FileWatcher } from "@/file/watcher" import { git } from "@/util/git" +import { Filesystem } from "@/util/filesystem" +import { Snapshot } from "@/snapshot" import { Effect, Layer, ServiceMap } from "effect" +import path from "path" const log = Log.create({ service: "vcs" }) +const cfg = [ + "-c", + "core.autocrlf=false", + "-c", + "core.fsmonitor=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + "-c", + "core.quotepath=false", +] as const + +type Base = { name: string; ref: string } + +async function mapLimit(list: T[], limit: number, fn: (item: T) => Promise) { + const size = Math.max(1, limit) + const out: R[] = new Array(list.length) + let idx = 0 + await Promise.all( + Array.from({ length: Math.min(size, list.length) }, async () => { + while (true) { + const i = idx + idx += 1 + if (i >= list.length) return + out[i] = await fn(list[i]!) + } + }), + ) + return out +} + +function out(result: { text(): string }) { + return result.text().trim() +} + +async function run(cwd: string, args: string[]) { + return git([...cfg, ...args], { cwd }) +} + +async function branch(cwd: string) { + const result = await run(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) + if (result.exitCode !== 0) return + const text = out(result) + return text || undefined +} + +async function prefix(cwd: string) { + const result = await run(cwd, ["rev-parse", "--show-prefix"]) + if (result.exitCode !== 0) return "" + return out(result) +} + +async function branches(cwd: string) { + const result = await run(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]) + if (result.exitCode !== 0) return [] + return out(result) + .split("\n") + .map((item) => item.trim()) + .filter(Boolean) +} + +async function remoteHead(cwd: string, remote: string) { + const result = await run(cwd, ["ls-remote", "--symref", remote, "HEAD"]) + if (result.exitCode !== 0) return + for (const line of result.text().split("\n")) { + const match = /^ref: refs\/heads\/(.+)\tHEAD$/.exec(line.trim()) + if (!match?.[1]) continue + return { name: match[1], ref: `${remote}/${match[1]}` } satisfies Base + } +} + +async function primary(cwd: string) { + const result = await run(cwd, ["remote"]) + const list = + result.exitCode !== 0 + ? [] + : out(result) + .split("\n") + .map((item) => item.trim()) + .filter(Boolean) + if (list.includes("origin")) return "origin" + if (list.length === 1) return list[0] + if (list.includes("upstream")) return "upstream" + return list[0] +} + +async function base(cwd: string) { + const remote = await primary(cwd) + if (remote) { + const head = await run(cwd, ["symbolic-ref", `refs/remotes/${remote}/HEAD`]) + if (head.exitCode === 0) { + const ref = out(head).replace(/^refs\/remotes\//, "") + const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" + if (name) return { name, ref } satisfies Base + } + + const next = await remoteHead(cwd, remote) + if (next) return next + } + + const list = await branches(cwd) + for (const name of ["main", "master"]) { + if (list.includes(name)) return { name, ref: name } + } +} + +async function head(cwd: string) { + const result = await run(cwd, ["rev-parse", "--verify", "HEAD"]) + return result.exitCode === 0 +} + +async function work(cwd: string, file: string) { + const full = path.join(cwd, file) + if (!(await Filesystem.exists(full))) return "" + const buf = await Filesystem.readBytes(full).catch(() => Buffer.alloc(0)) + if (buf.includes(0)) return "" + return buf.toString("utf8") +} + +async function show(cwd: string, ref: string, file: string, base: string) { + const target = base ? `${base}${file}` : file + const result = await run(cwd, ["show", `${ref}:${target}`]) + if (result.exitCode !== 0) return "" + return result.text() +} + +function kind(code: string | undefined): "added" | "deleted" | "modified" { + if (code?.startsWith("A")) return "added" + if (code?.startsWith("D")) return "deleted" + return "modified" +} + +function count(text: string) { + if (!text) return 0 + if (!text.endsWith("\n")) return text.split("\n").length + return text.slice(0, -1).split("\n").length +} + +async function track(cwd: string, ref: string) { + const base = await prefix(cwd) + const names = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--name-status", ref, "--", "."]) + const nums = await run(cwd, ["diff", "--no-ext-diff", "--no-renames", "--numstat", ref, "--", "."]) + const map = new Map() + const list: Snapshot.FileDiff[] = [] + + for (const line of out(names).split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!file) continue + map.set(file, kind(code)) + } + + const rows = out(nums).split("\n").filter(Boolean) + const next = await mapLimit(rows, 8, async (line) => { + const [adds, dels, file] = line.split("\t") + if (!file) return undefined + const binary = adds === "-" && dels === "-" + const status = map.get(file) ?? "modified" + const before = binary || status === "added" ? "" : await show(cwd, ref, file, base) + const after = binary || status === "deleted" ? "" : await work(cwd, file) + const add = binary ? 0 : Number.parseInt(adds || "0", 10) + const del = binary ? 0 : Number.parseInt(dels || "0", 10) + return { + file, + before, + after, + additions: Number.isFinite(add) ? add : 0, + deletions: Number.isFinite(del) ? del : 0, + status, + } satisfies Snapshot.FileDiff + }) + for (const item of next) { + if (item) list.push(item) + } + + const extra = await run(cwd, ["ls-files", "--others", "--exclude-standard", "--", "."]) + const added = await mapLimit(out(extra).split("\n").filter(Boolean), 16, async (file) => { + if (map.has(file)) return undefined + const after = await work(cwd, file) + return { + file, + before: "", + after, + additions: count(after), + deletions: 0, + status: "added", + } satisfies Snapshot.FileDiff + }) + for (const item of added) { + if (item) list.push(item) + } + + return list.toSorted((a, b) => a.file.localeCompare(b.file)) +} + +async function birth(cwd: string) { + const result = await run(cwd, ["ls-files", "--cached", "--others", "--exclude-standard", "--", "."]) + const list = await mapLimit(out(result).split("\n").filter(Boolean), 16, async (file) => { + const after = await work(cwd, file) + return { + file, + before: "", + after, + additions: count(after), + deletions: 0, + status: "added", + } satisfies Snapshot.FileDiff + }) + return list.toSorted((a, b) => a.file.localeCompare(b.file)) +} export namespace Vcs { + export const Mode = z.enum(["git", "branch"]) + export type Mode = z.infer + export const Event = { BranchUpdated: BusEvent.define( "vcs.branch.updated", @@ -22,7 +239,8 @@ export namespace Vcs { export const Info = z .object({ - branch: z.string(), + branch: z.string().optional(), + default_branch: z.string().optional(), }) .meta({ ref: "VcsInfo", @@ -34,6 +252,8 @@ export namespace VcsService { export interface Service { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect + readonly defaultBranch: () => Effect.Effect + readonly diff: (mode: Vcs.Mode) => Effect.Effect } } @@ -43,19 +263,15 @@ export class VcsService extends ServiceMap.Service { - const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: instance.project.worktree, - }) - if (result.exitCode !== 0) return undefined - const text = result.text().trim() - return text || undefined + return branch(instance.directory) } - current = yield* Effect.promise(() => currentBranch()) - log.info("initialized", { branch: current }) + ;[current, root] = yield* Effect.promise(() => Promise.all([currentBranch(), base(instance.directory)])) + log.info("initialized", { branch: current, default_branch: root?.name }) const unsubscribe = Bus.subscribe( FileWatcher.Event.Updated, @@ -78,6 +294,24 @@ export class VcsService extends ServiceMap.Service head(instance.directory)) + return yield* Effect.promise(() => (ok ? track(instance.directory, "HEAD") : birth(instance.directory))) + } + + if (!root) return [] + if (current && current === root.name) return [] + const ref = yield* Effect.promise(() => run(instance.project.worktree, ["merge-base", root.ref, "HEAD"])) + if (ref.exitCode !== 0) return [] + const text = out(ref) + if (!text) return [] + return yield* Effect.promise(() => track(instance.directory, text)) + }), }) }), ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 677af4da87f0..22b71b4acdb7 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -41,6 +41,7 @@ import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" +import { Snapshot } from "@/snapshot" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -331,10 +332,39 @@ export namespace Server { }, }), async (c) => { - const branch = await runPromiseInstance(VcsService.use((s) => s.branch())) - return c.json({ - branch, - }) + const [branch, default_branch] = await Promise.all([ + runPromiseInstance(VcsService.use((s) => s.branch())), + runPromiseInstance(VcsService.use((s) => s.defaultBranch())), + ]) + return c.json({ branch, default_branch }) + }, + ) + .get( + "/vcs/diff", + describeRoute({ + summary: "Get VCS diff", + description: "Retrieve the current git diff for the working tree or against the default branch.", + operationId: "vcs.diff", + responses: { + 200: { + description: "VCS diff", + content: { + "application/json": { + schema: resolver(Snapshot.FileDiff.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + mode: Vcs.Mode, + }), + ), + async (c) => { + const mode = c.req.valid("query").mode + return c.json(await runPromiseInstance(VcsService.use((s) => s.diff(mode)))) }, ) .get( diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index b5100585f5f0..afa74c9ad263 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -34,6 +34,10 @@ function withVcs( ) } +function withVcsOnly(directory: string, body: (rt: ManagedRuntime.ManagedRuntime) => Promise) { + return withServices(directory, VcsService.layer, body) +} + type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } /** Wait for a Vcs.Event.BranchUpdated event on GlobalBus */ @@ -115,3 +119,75 @@ describeVcs("Vcs", () => { }) }) }) + +describe("Vcs diff", () => { + afterEach(() => Instance.disposeAll()) + + test("defaultBranch() falls back to main", async () => { + await using tmp = await tmpdir({ git: true }) + await $`git branch -M main`.cwd(tmp.path).quiet() + + await withVcsOnly(tmp.path, async (rt) => { + const branch = await rt.runPromise(VcsService.use((s) => s.defaultBranch())) + expect(branch).toBe("main") + }) + }) + + test("detects current branch from the active worktree", async () => { + await using tmp = await tmpdir({ git: true }) + await using wt = await tmpdir() + await $`git branch -M main`.cwd(tmp.path).quiet() + const dir = path.join(wt.path, "feature") + await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() + + await withVcsOnly(dir, async (rt) => { + const [branch, base] = await Promise.all([ + rt.runPromise(VcsService.use((s) => s.branch())), + rt.runPromise(VcsService.use((s) => s.defaultBranch())), + ]) + expect(branch).toBe("feature/test") + expect(base).toBe("main") + }) + }) + + test("diff('git') returns uncommitted changes", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") + + await withVcsOnly(tmp.path, async (rt) => { + const diff = await rt.runPromise(VcsService.use((s) => s.diff("git"))) + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "file.txt", + status: "modified", + }), + ]), + ) + }) + }) + + test("diff('branch') returns changes against default branch", async () => { + await using tmp = await tmpdir({ git: true }) + await $`git branch -M main`.cwd(tmp.path).quiet() + await $`git checkout -b feature/test`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() + + await withVcsOnly(tmp.path, async (rt) => { + const diff = await rt.runPromise(VcsService.use((s) => s.diff("branch"))) + expect(diff).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "branch.txt", + status: "added", + }), + ]), + ) + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 27c188838b9b..9fa08933f6c8 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -172,6 +172,7 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, @@ -3657,6 +3658,38 @@ export class Vcs extends HeyApiClient { ...params, }) } + + /** + * Get VCS diff + * + * Retrieve the current git diff for the working tree or against the default branch. + */ + public diff( + parameters: { + directory?: string + workspace?: string + mode: "git" | "branch" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "mode" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/diff", + ...options, + ...params, + }) + } } export class Command extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fd80a51a214d..568deb21b185 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1861,7 +1861,8 @@ export type Path = { } export type VcsInfo = { - branch: string + branch?: string + default_branch?: string } export type Command = { @@ -4799,6 +4800,26 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] +export type VcsDiffData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + mode: "git" | "branch" + } + url: "/vcs/diff" +} + +export type VcsDiffResponses = { + /** + * VCS diff + */ + 200: Array +} + +export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] + export type CommandListData = { body?: never path?: never From 948138f385daca3c4baff1944a54460d5c878f69 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 17 Mar 2026 19:43:44 +0530 Subject: [PATCH 02/12] feat(app): add git review modes --- .../app/src/context/global-sync/bootstrap.ts | 2 +- .../src/context/global-sync/event-reducer.ts | 4 +- packages/app/src/i18n/en.ts | 2 + packages/app/src/pages/session.tsx | 192 +++++++++++++++--- .../src/pages/session/session-side-panel.tsx | 54 ++--- .../pages/session/use-session-commands.tsx | 6 +- packages/ui/src/i18n/en.ts | 2 + 7 files changed, 196 insertions(+), 66 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade0a..c71e00f6fa5d 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -158,7 +158,7 @@ export async function bootstrapDirectory(input: { input.sdk.vcs.get().then((x) => { const next = x.data ?? input.store.vcs input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) + if (next) input.vcsCache.setStore("value", next) }), input.sdk.permission.list().then((x) => { const grouped = groupBySession( diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b8eda0573f7b..0e243e95a36c 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -268,9 +268,9 @@ export function applyDirectoryEvent(input: { break } case "vcs.branch.updated": { - const props = event.properties as { branch: string } + const props = event.properties as { branch?: string } if (input.store.vcs?.branch === props.branch) break - const next = { branch: props.branch } + const next = { ...input.store.vcs, branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) break diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index ad12e1e0de58..35af5b7d320e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -532,6 +532,8 @@ export const dict = { "session.review.noVcs.createGit.action": "Create Git repository", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", + "session.review.noUncommittedChanges": "No uncommitted changes yet", + "session.review.noBranchChanges": "No branch changes yet", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6d29170081ab..6fe2e56b8e05 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { Project, UserMessage } from "@opencode-ai/sdk/v2" +import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { batch, @@ -57,6 +57,9 @@ import { formatServerError } from "@/utils/server-errors" const emptyUserMessages: UserMessage[] = [] const emptyFollowups: (FollowupDraft & { id: string })[] = [] +type ChangeMode = "git" | "branch" | "session" | "turn" +type VcsMode = "git" | "branch" + type SessionHistoryWindowInput = { sessionID: () => string | undefined messagesReady: () => boolean @@ -415,15 +418,16 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasReview = createMemo(() => reviewCount() > 0) + const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasSessionReview = createMemo(() => sessionCount() > 0) + const canReview = createMemo(() => !!params.id) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview, + hasReview: canReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -499,11 +503,22 @@ export default function Page() { const [store, setStore] = createStore({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", - changes: "session" as "session" | "turn", + changes: "git" as ChangeMode, newSessionWorktree: "main", deferRender: false, }) + const [vcs, setVcs] = createStore({ + diff: { + git: [] as FileDiff[], + branch: [] as FileDiff[], + }, + ready: { + git: false, + branch: false, + }, + }) + const [followup, setFollowup] = createStore({ items: {} as Record, sending: {} as Record, @@ -531,6 +546,40 @@ export default function Page() { let refreshTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined + const vcsTask = new Map>() + + const resetVcs = () => { + vcsTask.clear() + setVcs({ + diff: { git: [], branch: [] }, + ready: { git: false, branch: false }, + }) + } + + const loadVcs = (mode: VcsMode, force = false) => { + if (sync.project?.vcs !== "git") return Promise.resolve() + if (vcs.ready[mode] && !force) return Promise.resolve() + const current = vcsTask.get(mode) + if (current) return current + + const task = sdk.client.vcs + .diff({ mode }) + .then((result) => { + setVcs("diff", mode, result.data ?? []) + setVcs("ready", mode, true) + }) + .catch((error) => { + console.debug("[session-review] failed to load vcs diff", { mode, error }) + setVcs("diff", mode, []) + setVcs("ready", mode, true) + }) + .finally(() => { + vcsTask.delete(mode) + }) + + vcsTask.set(mode, task) + return task + } createComputed((prev) => { const open = desktopReviewOpen() @@ -546,7 +595,38 @@ export default function Page() { }, desktopReviewOpen()) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) - const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) + const changesOptions = createMemo(() => { + const list: ChangeMode[] = [] + const git = sync.project?.vcs === "git" + if (git) list.push("git") + if (git && sync.data.vcs?.branch && sync.data.vcs?.default_branch && sync.data.vcs.branch !== sync.data.vcs.default_branch) { + list.push("branch") + } + list.push("session", "turn") + return list + }) + const vcsMode = createMemo(() => { + if (store.changes === "git" || store.changes === "branch") return store.changes + }) + const reviewDiffs = createMemo(() => { + if (store.changes === "git") return vcs.diff.git + if (store.changes === "branch") return vcs.diff.branch + if (store.changes === "session") return diffs() + return turnDiffs() + }) + const reviewCount = createMemo(() => { + if (store.changes === "git") return vcs.diff.git.length + if (store.changes === "branch") return vcs.diff.branch.length + if (store.changes === "session") return sessionCount() + return turnDiffs().length + }) + const hasReview = createMemo(() => reviewCount() > 0) + const reviewReady = createMemo(() => { + if (store.changes === "git") return vcs.ready.git + if (store.changes === "branch") return vcs.ready.branch + if (store.changes === "session") return !hasSessionReview() || diffsReady() + return true + }) const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -615,10 +695,10 @@ export default function Page() { const diffsReady = createMemo(() => { const id = params.id if (!id) return true - if (!hasReview()) return true + if (!hasSessionReview()) return true return sync.data.session_diff[id] !== undefined }) - const reviewEmptyKey = createMemo(() => { + const sessionEmptyKey = createMemo(() => { const project = sync.project if (project && !project.vcs) return "session.review.noVcs" if (sync.data.config.snapshot === false) return "session.review.noSnapshot" @@ -741,13 +821,23 @@ export default function Page() { sessionKey, () => { setStore("messageId", undefined) - setStore("changes", "session") + setStore("changes", "git") setUi("pendingMessage", undefined) }, { defer: true }, ), ) + createEffect( + on( + () => sdk.directory, + () => { + resetVcs() + }, + { defer: true }, + ), + ) + createEffect( on( () => params.dir, @@ -870,6 +960,38 @@ export default function Page() { } const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") + const wantsReview = createMemo(() => + isDesktop() ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes", + ) + + createEffect(() => { + const list = changesOptions() + if (list.includes(store.changes)) return + const next = list[0] + if (!next) return + setStore("changes", next) + }) + + createEffect(() => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + void loadVcs(mode) + }) + + createEffect( + on( + () => sync.data.session_status[params.id ?? ""]?.type, + (next, prev) => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + if (next !== "idle" || prev === undefined || prev === "idle") return + void loadVcs(mode, true) + }, + { defer: true }, + ), + ) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -916,21 +1038,23 @@ export default function Page() { loadFile: file.load, }) - const changesOptions = ["session", "turn"] as const - const changesOptionsList = [...changesOptions] - const changesTitle = () => { - if (!hasReview()) { + if (!canReview()) { return null } + const label = (option: ChangeMode) => { + if (option === "git") return language.t("ui.sessionReview.title.git") + if (option === "branch") return language.t("ui.sessionReview.title.branch") + if (option === "session") return language.t("ui.sessionReview.title") + return language.t("ui.sessionReview.title.lastTurn") + } + return (