Skip to content

Commit e6f5214

Browse files
authored
feat: add git-backed session review modes (#17961)
1 parent 84f60d9 commit e6f5214

26 files changed

Lines changed: 1072 additions & 183 deletions

File tree

packages/app/src/context/global-sync/bootstrap.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export async function bootstrapDirectory(input: {
158158
input.sdk.vcs.get().then((x) => {
159159
const next = x.data ?? input.store.vcs
160160
input.setStore("vcs", next)
161-
if (next?.branch) input.vcsCache.setStore("value", next)
161+
if (next) input.vcsCache.setStore("value", next)
162162
}),
163163
input.sdk.permission.list().then((x) => {
164164
const grouped = groupBySession(

packages/app/src/context/global-sync/event-reducer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,9 @@ export function applyDirectoryEvent(input: {
268268
break
269269
}
270270
case "vcs.branch.updated": {
271-
const props = event.properties as { branch: string }
271+
const props = event.properties as { branch?: string }
272272
if (input.store.vcs?.branch === props.branch) break
273-
const next = { branch: props.branch }
273+
const next = { ...input.store.vcs, branch: props.branch }
274274
input.setStore("vcs", next)
275275
if (input.vcsCache) input.vcsCache.setStore("value", next)
276276
break

packages/app/src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,8 @@ export const dict = {
533533
"session.review.noVcs.createGit.action": "Create Git repository",
534534
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
535535
"session.review.noChanges": "No changes",
536+
"session.review.noUncommittedChanges": "No uncommitted changes yet",
537+
"session.review.noBranchChanges": "No branch changes yet",
536538

537539
"session.files.selectToOpen": "Select a file to open",
538540
"session.files.all": "All files",

packages/app/src/pages/session.tsx

Lines changed: 168 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
1+
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
22
import { useDialog } from "@opencode-ai/ui/context/dialog"
33
import {
44
batch,
@@ -57,6 +57,9 @@ import { formatServerError } from "@/utils/server-errors"
5757
const emptyUserMessages: UserMessage[] = []
5858
const emptyFollowups: (FollowupDraft & { id: string })[] = []
5959

60+
type ChangeMode = "git" | "branch" | "session" | "turn"
61+
type VcsMode = "git" | "branch"
62+
6063
type SessionHistoryWindowInput = {
6164
sessionID: () => string | undefined
6265
messagesReady: () => boolean
@@ -415,15 +418,16 @@ export default function Page() {
415418

416419
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
417420
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
418-
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
419-
const hasReview = createMemo(() => reviewCount() > 0)
421+
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
422+
const hasSessionReview = createMemo(() => sessionCount() > 0)
423+
const canReview = createMemo(() => !!params.id)
420424
const reviewTab = createMemo(() => isDesktop())
421425
const tabState = createSessionTabs({
422426
tabs,
423427
pathFromTab: file.pathFromTab,
424428
normalizeTab,
425429
review: reviewTab,
426-
hasReview,
430+
hasReview: canReview,
427431
})
428432
const contextOpen = tabState.contextOpen
429433
const openedTabs = tabState.openedTabs
@@ -499,11 +503,22 @@ export default function Page() {
499503
const [store, setStore] = createStore({
500504
messageId: undefined as string | undefined,
501505
mobileTab: "session" as "session" | "changes",
502-
changes: "session" as "session" | "turn",
506+
changes: "git" as ChangeMode,
503507
newSessionWorktree: "main",
504508
deferRender: false,
505509
})
506510

511+
const [vcs, setVcs] = createStore({
512+
diff: {
513+
git: [] as FileDiff[],
514+
branch: [] as FileDiff[],
515+
},
516+
ready: {
517+
git: false,
518+
branch: false,
519+
},
520+
})
521+
507522
const [followup, setFollowup] = createStore({
508523
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
509524
sending: {} as Record<string, string | undefined>,
@@ -531,6 +546,40 @@ export default function Page() {
531546
let refreshTimer: number | undefined
532547
let diffFrame: number | undefined
533548
let diffTimer: number | undefined
549+
const vcsTask = new Map<VcsMode, Promise<void>>()
550+
551+
const resetVcs = () => {
552+
vcsTask.clear()
553+
setVcs({
554+
diff: { git: [], branch: [] },
555+
ready: { git: false, branch: false },
556+
})
557+
}
558+
559+
const loadVcs = (mode: VcsMode, force = false) => {
560+
if (sync.project?.vcs !== "git") return Promise.resolve()
561+
if (vcs.ready[mode] && !force) return Promise.resolve()
562+
const current = vcsTask.get(mode)
563+
if (current) return current
564+
565+
const task = sdk.client.vcs
566+
.diff({ mode })
567+
.then((result) => {
568+
setVcs("diff", mode, result.data ?? [])
569+
setVcs("ready", mode, true)
570+
})
571+
.catch((error) => {
572+
console.debug("[session-review] failed to load vcs diff", { mode, error })
573+
setVcs("diff", mode, [])
574+
setVcs("ready", mode, true)
575+
})
576+
.finally(() => {
577+
vcsTask.delete(mode)
578+
})
579+
580+
vcsTask.set(mode, task)
581+
return task
582+
}
534583

535584
createComputed((prev) => {
536585
const open = desktopReviewOpen()
@@ -546,7 +595,38 @@ export default function Page() {
546595
}, desktopReviewOpen())
547596

548597
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
549-
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
598+
const changesOptions = createMemo<ChangeMode[]>(() => {
599+
const list: ChangeMode[] = []
600+
const git = sync.project?.vcs === "git"
601+
if (git) list.push("git")
602+
if (git && sync.data.vcs?.branch && sync.data.vcs?.default_branch && sync.data.vcs.branch !== sync.data.vcs.default_branch) {
603+
list.push("branch")
604+
}
605+
list.push("session", "turn")
606+
return list
607+
})
608+
const vcsMode = createMemo<VcsMode | undefined>(() => {
609+
if (store.changes === "git" || store.changes === "branch") return store.changes
610+
})
611+
const reviewDiffs = createMemo(() => {
612+
if (store.changes === "git") return vcs.diff.git
613+
if (store.changes === "branch") return vcs.diff.branch
614+
if (store.changes === "session") return diffs()
615+
return turnDiffs()
616+
})
617+
const reviewCount = createMemo(() => {
618+
if (store.changes === "git") return vcs.diff.git.length
619+
if (store.changes === "branch") return vcs.diff.branch.length
620+
if (store.changes === "session") return sessionCount()
621+
return turnDiffs().length
622+
})
623+
const hasReview = createMemo(() => reviewCount() > 0)
624+
const reviewReady = createMemo(() => {
625+
if (store.changes === "git") return vcs.ready.git
626+
if (store.changes === "branch") return vcs.ready.branch
627+
if (store.changes === "session") return !hasSessionReview() || diffsReady()
628+
return true
629+
})
550630

551631
const newSessionWorktree = createMemo(() => {
552632
if (store.newSessionWorktree === "create") return "create"
@@ -615,10 +695,10 @@ export default function Page() {
615695
const diffsReady = createMemo(() => {
616696
const id = params.id
617697
if (!id) return true
618-
if (!hasReview()) return true
698+
if (!hasSessionReview()) return true
619699
return sync.data.session_diff[id] !== undefined
620700
})
621-
const reviewEmptyKey = createMemo(() => {
701+
const sessionEmptyKey = createMemo(() => {
622702
const project = sync.project
623703
if (project && !project.vcs) return "session.review.noVcs"
624704
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
@@ -741,13 +821,23 @@ export default function Page() {
741821
sessionKey,
742822
() => {
743823
setStore("messageId", undefined)
744-
setStore("changes", "session")
824+
setStore("changes", "git")
745825
setUi("pendingMessage", undefined)
746826
},
747827
{ defer: true },
748828
),
749829
)
750830

831+
createEffect(
832+
on(
833+
() => sdk.directory,
834+
() => {
835+
resetVcs()
836+
},
837+
{ defer: true },
838+
),
839+
)
840+
751841
createEffect(
752842
on(
753843
() => params.dir,
@@ -870,6 +960,38 @@ export default function Page() {
870960
}
871961

872962
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
963+
const wantsReview = createMemo(() =>
964+
isDesktop() ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes",
965+
)
966+
967+
createEffect(() => {
968+
const list = changesOptions()
969+
if (list.includes(store.changes)) return
970+
const next = list[0]
971+
if (!next) return
972+
setStore("changes", next)
973+
})
974+
975+
createEffect(() => {
976+
const mode = vcsMode()
977+
if (!mode) return
978+
if (!wantsReview()) return
979+
void loadVcs(mode)
980+
})
981+
982+
createEffect(
983+
on(
984+
() => sync.data.session_status[params.id ?? ""]?.type,
985+
(next, prev) => {
986+
const mode = vcsMode()
987+
if (!mode) return
988+
if (!wantsReview()) return
989+
if (next !== "idle" || prev === undefined || prev === "idle") return
990+
void loadVcs(mode, true)
991+
},
992+
{ defer: true },
993+
),
994+
)
873995

874996
const fileTreeTab = () => layout.fileTree.tab()
875997
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -916,21 +1038,23 @@ export default function Page() {
9161038
loadFile: file.load,
9171039
})
9181040

919-
const changesOptions = ["session", "turn"] as const
920-
const changesOptionsList = [...changesOptions]
921-
9221041
const changesTitle = () => {
923-
if (!hasReview()) {
1042+
if (!canReview()) {
9241043
return null
9251044
}
9261045

1046+
const label = (option: ChangeMode) => {
1047+
if (option === "git") return language.t("ui.sessionReview.title.git")
1048+
if (option === "branch") return language.t("ui.sessionReview.title.branch")
1049+
if (option === "session") return language.t("ui.sessionReview.title")
1050+
return language.t("ui.sessionReview.title.lastTurn")
1051+
}
1052+
9271053
return (
9281054
<Select
929-
options={changesOptionsList}
1055+
options={changesOptions()}
9301056
current={store.changes}
931-
label={(option) =>
932-
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
933-
}
1057+
label={label}
9341058
onSelect={(option) => option && setStore("changes", option)}
9351059
variant="ghost"
9361060
size="small"
@@ -939,20 +1063,34 @@ export default function Page() {
9391063
)
9401064
}
9411065

942-
const emptyTurn = () => (
1066+
const empty = (text: string) => (
9431067
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
944-
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
1068+
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
9451069
</div>
9461070
)
9471071

1072+
const reviewEmptyText = createMemo(() => {
1073+
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
1074+
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
1075+
if (store.changes === "turn") return language.t("session.review.noChanges")
1076+
return language.t(sessionEmptyKey())
1077+
})
1078+
9481079
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
949-
if (store.changes === "turn") return emptyTurn()
1080+
if (store.changes === "git" || store.changes === "branch") {
1081+
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
1082+
return empty(reviewEmptyText())
1083+
}
1084+
1085+
if (store.changes === "turn") {
1086+
return empty(reviewEmptyText())
1087+
}
9501088

951-
if (hasReview() && !diffsReady()) {
1089+
if (hasSessionReview() && !diffsReady()) {
9521090
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
9531091
}
9541092

955-
if (reviewEmptyKey() === "session.review.noVcs") {
1093+
if (sessionEmptyKey() === "session.review.noVcs") {
9561094
return (
9571095
<div class={input.emptyClass}>
9581096
<div class="flex flex-col gap-3">
@@ -972,7 +1110,7 @@ export default function Page() {
9721110

9731111
return (
9741112
<div class={input.emptyClass}>
975-
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
1113+
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
9761114
</div>
9771115
)
9781116
}
@@ -1076,7 +1214,7 @@ export default function Page() {
10761214
const pending = tree.pendingDiff
10771215
if (!pending) return
10781216
if (!tree.reviewScroll) return
1079-
if (!diffsReady()) return
1217+
if (!reviewReady()) return
10801218

10811219
const attempt = (count: number) => {
10821220
if (tree.pendingDiff !== pending) return
@@ -1808,6 +1946,12 @@ export default function Page() {
18081946
</div>
18091947

18101948
<SessionSidePanel
1949+
canReview={canReview}
1950+
diffs={reviewDiffs}
1951+
diffsReady={reviewReady}
1952+
empty={reviewEmptyText}
1953+
hasReview={hasReview}
1954+
reviewCount={reviewCount}
18111955
reviewPanel={reviewPanel}
18121956
activeDiff={tree.activeDiff}
18131957
focusReviewDiff={focusReviewDiff}

0 commit comments

Comments
 (0)