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
6 changes: 6 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,13 @@ export const dict = {
"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.noUnstagedChanges": "No unstaged changes yet",
"session.review.noStagedChanges": "No staged changes yet",
"session.review.noBranchChanges": "No branch changes yet",
"ui.sessionReview.title.unstaged": "Unstaged",
"ui.sessionReview.title.staged": "Staged",
"ui.sessionReview.title.branch": "Branch",
"ui.sessionReview.title.lastTurn": "Last Turn",

"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/i18n/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ const keys = [
"session.panel.utility",
"session.panel.files",
"session.panel.changes",
"session.review.noUnstagedChanges",
"session.review.noStagedChanges",
"session.review.noBranchChanges",
"ui.sessionReview.title.unstaged",
"ui.sessionReview.title.staged",
"ui.sessionReview.title.branch",
"ui.sessionReview.title.lastTurn",
] as const

describe("i18n parity", () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,13 @@ export const dict = {
"session.review.noVcs": "未检测到 Git 版本控制系统,无法显示更改",
"session.review.noSnapshot": "配置中已禁用快照跟踪,因此会话更改不可用",
"session.review.noChanges": "无更改",
"session.review.noUnstagedChanges": "暂无未暂存变更",
"session.review.noStagedChanges": "暂无已暂存变更",
"session.review.noBranchChanges": "暂无分支变更",
"ui.sessionReview.title.unstaged": "未暂存变更",
"ui.sessionReview.title.staged": "已暂存变更",
"ui.sessionReview.title.branch": "分支变更",
"ui.sessionReview.title.lastTurn": "上轮变更",
"session.files.selectToOpen": "选择要打开的文件",
"session.files.all": "所有文件",
"session.files.empty": "无文件",
Expand Down
157 changes: 45 additions & 112 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import {
Expand Down Expand Up @@ -26,7 +26,6 @@ import { Select } from "@opencode-ai/ui/select"
import { Tabs } from "@opencode-ai/ui/tabs"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router"
Expand Down Expand Up @@ -54,6 +53,17 @@ import {
} from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import {
coerceReviewChangeMode,
DEFAULT_REVIEW_CHANGE_MODE,
isVcsReviewMode,
nextReviewModeForSessionChange,
reviewChangeOptions,
reviewDiffsForMode,
reviewModeLabelKey,
type ReviewChangeMode,
type VcsReviewMode,
} from "@/pages/session/review-change-mode"
import { useSessionLayout } from "@/pages/session/session-layout"
import {
emptyMessages,
Expand All @@ -80,9 +90,6 @@ type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []

type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"

type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
Expand Down Expand Up @@ -614,27 +621,23 @@ export default function Page() {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode,
changes: DEFAULT_REVIEW_CHANGE_MODE as ReviewChangeMode,
newSessionWorktree: "main",
deferRender: false,
})

const [vcs, setVcs] = createStore<{
diff: {
git: VcsFileDiff[]
branch: VcsFileDiff[]
}
ready: {
git: boolean
branch: boolean
}
diff: Record<VcsReviewMode, VcsFileDiff[]>
ready: Record<VcsReviewMode, boolean>
}>({
diff: {
git: [] as VcsFileDiff[],
unstaged: [] as VcsFileDiff[],
staged: [] as VcsFileDiff[],
branch: [] as VcsFileDiff[],
},
ready: {
git: false,
unstaged: false,
staged: false,
branch: false,
},
})
Expand Down Expand Up @@ -671,26 +674,26 @@ export default function Page() {
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
const vcsTask = new Map<VcsMode, Promise<void>>()
const vcsRun = new Map<VcsMode, number>()
const vcsTask = new Map<VcsReviewMode, Promise<void>>()
const vcsRun = new Map<VcsReviewMode, number>()

const bumpVcs = (mode: VcsMode) => {
const bumpVcs = (mode: VcsReviewMode) => {
const next = (vcsRun.get(mode) ?? 0) + 1
vcsRun.set(mode, next)
return next
}

const resetVcs = (mode?: VcsMode) => {
const list = mode ? [mode] : (["git", "branch"] as const)
list.forEach((item) => {
const resetVcs = (mode?: VcsReviewMode) => {
const modes = mode ? [mode] : (["unstaged", "staged", "branch"] as const)
modes.forEach((item) => {
bumpVcs(item)
vcsTask.delete(item)
setVcs("diff", item, [])
setVcs("ready", item, false)
})
}

const loadVcs = (mode: VcsMode, force = false) => {
const loadVcs = (mode: VcsReviewMode, force = false) => {
if (sync.project?.vcs !== "git") return Promise.resolve()
if (!force && vcs.ready[mode]) return Promise.resolve()

Expand Down Expand Up @@ -761,34 +764,24 @@ export default function Page() {
}),
)
})
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
if (
sync.project?.vcs === "git" &&
sync.data.vcs?.branch &&
sync.data.vcs?.default_branch &&
sync.data.vcs.branch !== sync.data.vcs.default_branch
) {
list.push("branch")
}
list.push("turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
const changesOptions = createMemo<ReviewChangeMode[]>(() =>
reviewChangeOptions({ isGit: sync.project?.vcs === "git" }),
)
const vcsMode = createMemo<VcsReviewMode | undefined>(() => {
if (isVcsReviewMode(store.changes)) return store.changes
})
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return list(vcs.diff.git)
if (store.changes === "branch") return list(vcs.diff.branch)
return turnDiffs()
return list(
reviewDiffsForMode(store.changes, {
turn: turnDiffs(),
vcs: vcs.diff,
}),
)
})
const reviewCount = createMemo(() => reviewDiffs().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 (isVcsReviewMode(store.changes)) return vcs.ready[store.changes]
return true
})

Expand Down Expand Up @@ -856,45 +849,6 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto")
}

function upsert(next: Project) {
const list = globalSync.data.project
sync.set("project", next.id)
const idx = list.findIndex((item) => item.id === next.id)
if (idx >= 0) {
globalSync.set(
"project",
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
)
return
}
const at = list.findIndex((item) => item.id > next.id)
if (at >= 0) {
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
}
globalSync.set("project", [...list, next])
}

const gitMutation = useMutation(() => ({
mutationFn: () => sdk.client.project.initGit(),
onSuccess: (x) => {
if (!x.data) return
upsert(x.data)
},
onError: (err) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: formatServerError(err, language.t),
})
},
}))

function initGit() {
if (gitMutation.isPending) return
gitMutation.mutate()
}

let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let dockHeight = 0
Expand Down Expand Up @@ -1003,7 +957,7 @@ export default function Page() {
sessionKey,
() => {
setStore("messageId", undefined)
setStore("changes", "git")
setStore("changes", nextReviewModeForSessionChange())
setUi("pendingMessage", undefined)
},
{ defer: true },
Expand Down Expand Up @@ -1207,9 +1161,8 @@ export default function Page() {

createEffect(() => {
const list = changesOptions()
if (list.includes(store.changes)) return
const next = list[0]
if (!next) return
const next = coerceReviewChangeMode(store.changes, list)
if (next === store.changes) return
setStore("changes", next)
})

Expand Down Expand Up @@ -1287,11 +1240,7 @@ export default function Page() {
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")
return language.t("ui.sessionReview.title.lastTurn")
}
const label = (option: ReviewChangeMode) => language.t(reviewModeLabelKey(option))

return (
<Select
Expand All @@ -1312,36 +1261,20 @@ export default function Page() {
</div>
)

const createGit = (input: { emptyClass: string }) => (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-13-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-13-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</div>
)

const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "unstaged") return language.t("session.review.noUnstagedChanges")
if (store.changes === "staged") return language.t("session.review.noStagedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
return language.t("session.review.noChanges")
})

const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
if (store.changes === "git" || store.changes === "branch") {
if (isVcsReviewMode(store.changes)) {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}

if (store.changes === "turn") {
if (nogit()) return createGit(input)
return empty(reviewEmptyText())
}
Comment thread
Astro-Han marked this conversation as resolved.

Expand Down
71 changes: 71 additions & 0 deletions packages/app/src/pages/session/review-change-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import {
coerceReviewChangeMode,
DEFAULT_REVIEW_CHANGE_MODE,
isVcsReviewMode,
nextReviewModeForSessionChange,
reviewChangeOptions,
reviewDiffsForMode,
reviewModeLabelKey,
} from "./review-change-mode"

describe("review change mode", () => {
test("defaults to last turn", () => {
expect(DEFAULT_REVIEW_CHANGE_MODE).toBe("turn")
})

test("keeps all review modes selectable for git projects", () => {
expect(reviewChangeOptions({ isGit: true })).toEqual(["unstaged", "staged", "branch", "turn"])
})

test("keeps branch selectable even when the branch diff is empty", () => {
expect(reviewChangeOptions({ isGit: true })).toContain("branch")
})

test("limits non-git projects to last turn", () => {
expect(reviewChangeOptions({ isGit: false })).toEqual(["turn"])
})

test("falls back to last turn when the selected mode is unavailable", () => {
expect(coerceReviewChangeMode("branch", ["turn"])).toBe("turn")
})

test("identifies VCS-backed review modes", () => {
expect(isVcsReviewMode("unstaged")).toBe(true)
expect(isVcsReviewMode("staged")).toBe(true)
expect(isVcsReviewMode("branch")).toBe(true)
expect(isVcsReviewMode("turn")).toBe(false)
})

test("maps modes to translation keys", () => {
expect(reviewModeLabelKey("unstaged")).toBe("ui.sessionReview.title.unstaged")
expect(reviewModeLabelKey("staged")).toBe("ui.sessionReview.title.staged")
expect(reviewModeLabelKey("branch")).toBe("ui.sessionReview.title.branch")
expect(reviewModeLabelKey("turn")).toBe("ui.sessionReview.title.lastTurn")
})

test("resets session changes to last turn", () => {
expect(nextReviewModeForSessionChange()).toBe("turn")
})

test("uses turn diffs without falling back to VCS diffs", () => {
const turn = ["turn diff"]
const vcs = {
unstaged: ["unstaged diff"],
staged: ["staged diff"],
branch: ["branch diff"],
}

expect(reviewDiffsForMode("turn", { turn, vcs })).toEqual(turn)
})

test("keeps an empty last turn empty when VCS diffs exist", () => {
const vcs = {
unstaged: ["unstaged diff"],
staged: ["staged diff"],
branch: ["branch diff"],
}

expect(reviewDiffsForMode("turn", { turn: [], vcs })).toEqual([])
})
})
Loading
Loading