feat(app): persist review state per session (#35488)

This commit is contained in:
Luke Parker 2026-07-09 09:43:28 +10:00 committed by GitHub
commit aa52d30d7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 222 additions and 23 deletions

View file

@ -59,6 +59,8 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
reviewMode?: ReviewChangeMode
reviewFile?: string
pendingMessage?: string
pendingMessageAt?: number
todoCollapsed?: boolean
@ -75,6 +77,7 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
@ -786,6 +789,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
view(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
const mode = s().reviewMode
if (mode === "git" || mode === "branch" || mode === "turn") return mode
})
const reviewFile = createMemo(() => {
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
@ -869,6 +880,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
review: {
mode: reviewMode,
setMode(mode: ReviewChangeMode) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewMode: mode })
prune(session)
return
}
if (current.reviewMode === mode) return
setStore("sessionView", session, "reviewMode", mode)
prune(session)
},
file: reviewFile,
setFile(file: string) {
const session = key()
const current = store.sessionView[session]
if (!current) {
setStore("sessionView", session, { scroll: {}, reviewFile: file })
prune(session)
return
}
if (current.reviewFile === file) return
setStore("sessionView", session, "reviewFile", file)
prune(session)
},
open: createMemo(() => s().reviewOpen ?? []),
setOpen(open: string[]) {
const session = key()

View file

@ -101,7 +101,6 @@ type VcsMode = "git" | "branch"
const sessionViewState = () => ({
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "changes",
changes: "git" as ChangeMode,
})
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
@ -348,6 +347,8 @@ export default function Page() {
const location = useLocation()
const navigate = useNavigate()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const reviewMode = () => view().review.mode() ?? "git"
const reviewFile = () => view().review.file()
const sessionOwnership = createSessionOwnership(sessionKey)
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
@ -607,7 +608,8 @@ export default function Page() {
: store.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
if (store.changes === "git" || store.changes === "branch") return store.changes
const mode = reviewMode()
if (mode === "git" || mode === "branch") return mode
})
const vcsKey = createMemo(
() =>
@ -634,15 +636,21 @@ export default function Page() {
})
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
const reviewDiffs = () => {
if (store.changes === "git" || store.changes === "branch")
if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense
return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return turnDiffs()
}
const activeReviewFile = () => {
const diffs = reviewDiffs()
const selected = reviewFile()
if (selected && diffs.some((diff) => diff.file === selected)) return selected
return diffs[0]?.file
}
const reviewCount = () => reviewDiffs().length
const hasReview = () => reviewCount() > 0
const reviewReady = () => {
if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending
if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending
return true
}
const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | undefined> => {
@ -1002,12 +1010,15 @@ export default function Page() {
}
createEffect(() => {
if (!layout.ready()) return
if (sync().status !== "complete") return
if (!sync().project) return
const list = changesOptions()
if (list.includes(store.changes)) return
const mode = reviewMode()
if (list.includes(mode)) return
const next = list[0]
if (!next) return
setStore("changes", next)
view().review.setMode(next)
})
createEffect(
@ -1027,7 +1038,6 @@ export default function Page() {
const [tree, setTree] = createStore({
reviewScroll: undefined as HTMLDivElement | undefined,
pendingDiff: undefined as string | undefined,
activeDiff: undefined as string | undefined,
})
createEffect(
@ -1037,7 +1047,6 @@ export default function Page() {
setTree({
reviewScroll: undefined,
pendingDiff: undefined,
activeDiff: undefined,
})
},
{ defer: true },
@ -1085,9 +1094,9 @@ export default function Page() {
return (
<Select
options={changesOptions()}
current={store.changes}
current={reviewMode()}
label={changesLabel}
onSelect={(option) => option && setStore("changes", option)}
onSelect={(option) => option && view().review.setMode(option)}
variant="ghost"
size="small"
valueClass="text-14-medium"
@ -1104,11 +1113,11 @@ export default function Page() {
<SelectV2
appearance="inline"
options={changesOptions()}
current={store.changes}
current={reviewMode()}
label={changesLabel}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && setStore("changes", option)}
onSelect={(option) => option && view().review.setMode(option)}
/>
)
}
@ -1136,18 +1145,18 @@ export default function Page() {
)
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (reviewMode() === "git") return language.t("session.review.noUncommittedChanges")
if (reviewMode() === "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 (reviewMode() === "git" || reviewMode() === "branch") {
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
return empty(reviewEmptyText())
}
if (store.changes === "turn") {
if (reviewMode() === "turn") {
if (nogit()) return createGit(input)
return empty(reviewEmptyText())
}
@ -1160,10 +1169,10 @@ export default function Page() {
}
const reviewEmptyV2 = () => {
if ((store.changes === "git" || store.changes === "branch") && !reviewReady()) {
if ((reviewMode() === "git" || reviewMode() === "branch") && !reviewReady()) {
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
}
if (store.changes === "turn" && nogit()) {
if (reviewMode() === "turn" && nogit()) {
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
}
return <SessionReviewEmptyChangesV2 />
@ -1185,7 +1194,7 @@ export default function Page() {
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
focusedFile={activeReviewFile()}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
@ -1221,7 +1230,7 @@ export default function Page() {
},
loadDiff: loadReviewDiff,
get activeFile() {
return tree.activeDiff
return activeReviewFile()
},
onSelectFile: focusReviewDiff,
get diffStyle() {
@ -1334,7 +1343,8 @@ export default function Page() {
const focusReviewDiff = (path: string) => {
openReviewPanel()
view().review.openPath(path)
setTree({ activeDiff: path, pendingDiff: path })
view().review.setFile(path)
setTree("pendingDiff", path)
}
createEffect(() => {
@ -2198,7 +2208,7 @@ export default function Page() {
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
@ -2224,7 +2234,7 @@ export default function Page() {
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
fileBrowserState={reviewV2State}
activeDiff={tree.activeDiff}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}