Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
161 changes: 130 additions & 31 deletions app-prefixable/src/components/review-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import * as Diff from "diff";
import type { FileDiff, FileNode } from "../sdk/client";
import { useSDK } from "../context/sdk";
import { useEvents } from "../context/events";
import { useLayout } from "../context/layout";
import { useLayout, type ReviewMode } from "../context/layout";
import { useSync } from "../context/sync";

import { FileTree } from "./file-tree";
import { FileViewer } from "./file-viewer";
Expand Down Expand Up @@ -41,63 +42,130 @@ interface ReviewPanelProps {
sessionId: string;
}

const REVIEW_MODES: Array<{ value: ReviewMode; label: string }> = [
{ value: "session", label: "Session" },
{ value: "git", label: "Git" },
{ value: "branch", label: "Branch" },
{ value: "turn", label: "Turn" },
];

function lastUserMessageID(
messages: Array<{ info: { id: string; role: string } }>,
) {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].info.role === "user") return messages[i].info.id;
}
return undefined;
}

export function ReviewPanel(props: ReviewPanelProps) {
const { client, directory } = useSDK();
const events = useEvents();
const layout = useLayout();
const sync = useSync();

const [diffs, setDiffs] = createSignal<FileDiff[]>([]);
const [selected, setSelected] = createSignal<string | null>(null);
const [loading, setLoading] = createSignal(false);
const [tab, setTab] = createSignal<"changes" | "all">("changes");
const [isGitRepo, setIsGitRepo] = createSignal<boolean | null>(null); // null = unknown

const mode = () => layout.review.mode();
const lastUserMessageId = createMemo(() =>
lastUserMessageID(sync.messages(props.sessionId)),
);

// Track the latest request to prevent race conditions
let version = 0;

async function checkGitRepo() {
async function checkGitRepo(current: number) {
try {
// Try to get VCS info - if it fails or returns no branch, it's not a git repo
const res = await client.vcs.get({ directory });
if (current !== version) return;
setIsGitRepo(res.data?.branch !== undefined);
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment in checkGitRepo says "if it fails or returns no branch", but VcsInfo.branch is a required string in the generated types. To avoid misleading future readers, consider updating the comment to reflect the actual check being performed (i.e., "if the request fails, treat it as not a Git repo"), or adjust the condition if the backend can legitimately return an empty/absent branch.

Suggested change
// Try to get VCS info - if it fails or returns no branch, it's not a git repo
const res = await client.vcs.get({ directory });
if (current !== version) return;
setIsGitRepo(res.data?.branch !== undefined);
// If the VCS info request succeeds, treat the directory as a Git repo.
await client.vcs.get({ directory });
if (current !== version) return;
setIsGitRepo(true);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6630c14. Updated checkGitRepo to treat a successful client.vcs.get call as definitive git-repo detection, and adjusted the comment accordingly (removed the branch-presence check since VcsInfo.branch is required by contract).

} catch {
if (current !== version) return;
setIsGitRepo(false);
}
Comment on lines +96 to 106
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkGitRepo() treats any failure of client.vcs.get() as “not a Git repository”. In git/branch mode this can incorrectly show the “Not a Git repository” empty state for transient/network/auth/server errors. Consider calling client.vcs.get with throwOnError: false (or using a non-throwing client) and only setting isGitRepo(false) for the specific status/error that indicates “not a git repo”; otherwise keep isGitRepo(null) and surface a generic load error state.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b3234eb. I switched checkGitRepo() to use a non-throwing client.vcs.get({ directory }, { throwOnError: false }) call, classify explicit not-a-git errors via message matching, and only set isGitRepo(false) for that case. For transient/auth/server/network failures, it now keeps isGitRepo(null) so the panel does not incorrectly render the "Not a Git repository" empty state.

}

function setFiles(files: FileDiff[]) {
setDiffs(files);
const paths = files.map((d) => d.file);
const sel = selected();
if (!sel || !paths.includes(sel)) {
setSelected(files.length > 0 ? files[0].file : null);
}
}

async function loadDiffs() {
const current = ++version;
const currentMode = mode();
setLoading(true);
try {
if (currentMode === "git" || currentMode === "branch") {
const res = await client.vcs.diff({ directory, mode: currentMode });
Comment on lines 119 to +126
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isGitRepo can retain a stale false value from a prior load/mode, which can briefly render the “Not a Git repository” empty state immediately after switching into git/branch mode (before loadDiffs() sets loading/recomputes repo status). Consider resetting isGitRepo to null at the start of loadDiffs() (or inside setMode) when entering git/branch modes so the UI doesn’t display an out-of-date repo state.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4b73df9. I now reset isGitRepo to null at the start of loadDiffs() when entering git/branch mode, so stale false does not briefly render the "Not a Git repository" state during mode switches.

if (current !== version) return;
Comment on lines +125 to +127
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a new dependency on the /vcs/diff endpoint for git/branch modes, but the existing API contract smoke tests don’t cover any VCS endpoints. Consider adding a lightweight contract test for GET /vcs/diff?mode=git (and/or mode=branch) to catch missing/renamed endpoints early, similar to the other endpoint availability checks in tests/api-contract.test.ts.

Copilot uses AI. Check for mistakes.
if (res.data) {
setFiles(res.data);
setIsGitRepo(true);
return;
}
setFiles([]);
await checkGitRepo(current);
return;
}

if (currentMode === "turn") {
const messageID = lastUserMessageId();
if (!messageID) {
if (current !== version) return;
setFiles([]);
setIsGitRepo(true);
return;
}
const res = await client.session.diff({
sessionID: props.sessionId,
directory,
messageID,
});
if (current !== version) return;
if (res.data) {
setFiles(res.data);
setIsGitRepo(true);
return;
}
setFiles([]);
await checkGitRepo(current);
return;
}

const res = await client.session.diff({ sessionID: props.sessionId, directory });
// Only update state if this is still the latest request
if (current !== version) return;
if (res.data) {
setDiffs(res.data);
setIsGitRepo(true); // If we got diffs, it's definitely a git repo
// Auto-select first file if none selected or selection no longer exists
const files = res.data.map((d) => d.file);
const sel = selected();
if (!sel || !files.includes(sel)) {
setSelected(res.data.length > 0 ? res.data[0].file : null);
}
} else {
// No diffs returned - check if it's because no git repo
await checkGitRepo();
setFiles(res.data);
setIsGitRepo(true);
return;
}
setFiles([]);
await checkGitRepo(current);
} catch (e) {
if (current !== version) return;
console.error("[ReviewPanel] Failed to load diffs:", e);
// Check if it's a git repo issue
await checkGitRepo();
setFiles([]);
await checkGitRepo(current);
} finally {
if (current === version) setLoading(false);
}
}

// Load diffs when session changes
// Load diffs when session, mode, or relevant turn changes
createEffect(() => {
const id = props.sessionId;
const currentMode = mode();
if (currentMode === "turn") lastUserMessageId();
if (id) {
// Reset selection when session changes
setSelected(null);
Expand All @@ -119,16 +187,9 @@ export function ReviewPanel(props: ReviewPanelProps) {
sessionID?: string;
diff?: FileDiff[];
};
if (eventProps.sessionID === id && eventProps.diff) {
setDiffs(eventProps.diff);
// Auto-select first file if none selected or selection no longer exists
const files = eventProps.diff.map((d) => d.file);
const sel = selected();
if (!sel || !files.includes(sel)) {
setSelected(
eventProps.diff.length > 0 ? eventProps.diff[0].file : null,
);
}
if (mode() === "session" && eventProps.sessionID === id && eventProps.diff) {
setFiles(eventProps.diff);
setIsGitRepo(true);
}
}
// Reload on session status idle to catch completed changes
Expand All @@ -142,7 +203,7 @@ export function ReviewPanel(props: ReviewPanelProps) {
}
}
if (event.type === "vcs.branch.updated") {
loadDiffs();
if (mode() === "git" || mode() === "branch") loadDiffs();
}
});

Expand Down Expand Up @@ -195,6 +256,13 @@ export function ReviewPanel(props: ReviewPanelProps) {
});

const count = createMemo(() => diffs().length);
const isGitMode = createMemo(() => mode() === "git" || mode() === "branch");
const emptyText = createMemo(() => {
if (mode() === "git") return "No uncommitted changes";
if (mode() === "branch") return "No branch changes";
if (mode() === "turn") return "No changes in the last turn";
return "No changes in this session";
});

// List of changed file paths for filtering
const diffFiles = createMemo(() => diffs().map((d) => d.file));
Expand Down Expand Up @@ -330,6 +398,8 @@ export function ReviewPanel(props: ReviewPanelProps) {
>
<div class="flex items-center gap-2">
<button
type="button"
aria-label="Refresh diff list"
onClick={() => loadDiffs()}
class="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
style={{ color: "var(--icon-weak)" }}
Expand All @@ -342,6 +412,8 @@ export function ReviewPanel(props: ReviewPanelProps) {
</button>
</div>
<button
type="button"
aria-label="Close review panel"
onClick={() => layout.review.close()}
class="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: "var(--icon-weak)" }}
Expand All @@ -350,7 +422,7 @@ export function ReviewPanel(props: ReviewPanelProps) {
</button>
</div>

{/* Tabs for Changed Files / All Files */}
{/* Review mode selector + Tabs for Changed Files / All Files */}
<Tabs
variant="pill"
value={tab()}
Expand All @@ -370,6 +442,33 @@ export function ReviewPanel(props: ReviewPanelProps) {
class="px-2 py-2"
style={{ "border-bottom": "1px solid var(--border-base)" }}
>
<div class="flex gap-1 mb-2" role="group" aria-label="Diff mode">
<For each={REVIEW_MODES}>
{(item) => (
<button
type="button"
onClick={() => layout.review.setMode(item.value)}
aria-pressed={mode() === item.value}
aria-label={`Switch to ${item.label.toLowerCase()} diff mode`}
class="flex-1 px-2 py-1 text-xs rounded transition-colors"
style={{
background:
mode() === item.value
? "var(--interactive-base)"
: "var(--surface-base)",
color:
mode() === item.value
? "var(--text-on-interactive)"
: "var(--text-weak)",
border: "1px solid var(--border-base)",
}}
title={`Show ${item.label.toLowerCase()} changes`}
>
{item.label}
</button>
)}
</For>
</div>
<Tabs.List class="flex gap-1">
<Tabs.Trigger
value="changes"
Expand Down Expand Up @@ -418,7 +517,7 @@ export function ReviewPanel(props: ReviewPanelProps) {
style={{ color: "var(--text-weak)" }}
>
<Show
when={isGitRepo() === true}
when={!isGitMode() || isGitRepo() !== false}
fallback={
<div class="flex flex-col items-center gap-2">
<GitBranch
Expand All @@ -435,7 +534,7 @@ export function ReviewPanel(props: ReviewPanelProps) {
</div>
}
>
<span>No changes in this session</span>
<span>{emptyText()}</span>
</Show>
</div>
}
Expand Down Expand Up @@ -469,7 +568,7 @@ export function ReviewPanel(props: ReviewPanelProps) {
>
{count() > 0
? "Select a file to view changes"
: "No changes in this session"}
: emptyText()}
</span>
</div>
}
Expand Down
24 changes: 24 additions & 0 deletions app-prefixable/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ const LAYOUT_STORAGE_KEY = "opencode.layout";
const DEFAULT_REVIEW_WIDTH = 320;
const DEFAULT_INFO_WIDTH = 256;
const DEFAULT_SIDEBAR_WIDTH = 256;
const DEFAULT_REVIEW_MODE = "session";
export const SIDEBAR_MIN_WIDTH = 180;
export const SIDEBAR_MAX_WIDTH = 480;

export type ReviewMode = "session" | "git" | "branch" | "turn";

interface PanelState {
opened: boolean;
width?: number;
Expand All @@ -29,6 +32,7 @@ interface LayoutState {
review: PanelState;
info: PanelState;
sidebar: { width?: number };
reviewMode?: ReviewMode;
tabs?: FileTab[];
activeTab?: string | null; // null = Review tab, string = file path
}
Expand All @@ -38,10 +42,12 @@ interface LayoutContextValue {
review: {
opened: () => boolean;
width: () => number;
mode: () => ReviewMode;
toggle: () => void;
open: () => void;
close: () => void;
resize: (width: number) => void;
setMode: (mode: ReviewMode) => void;
};
// Info panel (todos, context usage)
info: {
Expand Down Expand Up @@ -69,6 +75,10 @@ interface LayoutContextValue {

const LayoutContext = createContext<LayoutContextValue>();

function isReviewMode(value: unknown): value is ReviewMode {
return value === "session" || value === "git" || value === "branch" || value === "turn";
}

function basename(path: string) {
const idx = path.lastIndexOf("/");
return idx === -1 ? path : path.slice(idx + 1);
Expand Down Expand Up @@ -98,6 +108,9 @@ function loadState(): LayoutState {
sidebar: {
width: parsed.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH,
},
reviewMode: isReviewMode(parsed.reviewMode)
? parsed.reviewMode
: DEFAULT_REVIEW_MODE,
tabs,
activeTab,
};
Expand All @@ -109,6 +122,7 @@ function loadState(): LayoutState {
review: { opened: false, width: DEFAULT_REVIEW_WIDTH },
info: { opened: false, width: DEFAULT_INFO_WIDTH },
sidebar: { width: DEFAULT_SIDEBAR_WIDTH },
reviewMode: DEFAULT_REVIEW_MODE,
tabs: [],
activeTab: null,
};
Expand All @@ -130,6 +144,9 @@ export function LayoutProvider(props: ParentProps) {
const [reviewWidth, setReviewWidth] = createSignal(
initial.review.width ?? DEFAULT_REVIEW_WIDTH,
);
const [reviewMode, setReviewMode] = createSignal<ReviewMode>(
initial.reviewMode ?? DEFAULT_REVIEW_MODE,
);

// Info panel state
const [infoOpened, setInfoOpened] = createSignal(initial.info.opened);
Expand All @@ -156,6 +173,7 @@ export function LayoutProvider(props: ParentProps) {
review: { opened: reviewOpened(), width: reviewWidth() },
info: { opened: infoOpened(), width: infoWidth() },
sidebar: { width: sidebarWidth() },
reviewMode: reviewMode(),
tabs: fileTabs(),
activeTab: activeTab(),
});
Expand All @@ -165,6 +183,7 @@ export function LayoutProvider(props: ParentProps) {
review: {
opened: reviewOpened,
width: reviewWidth,
mode: reviewMode,
toggle: () => {
setReviewOpened((v) => !v);
persist();
Expand All @@ -181,6 +200,11 @@ export function LayoutProvider(props: ParentProps) {
setReviewWidth(width);
persist();
},
setMode: (mode: ReviewMode) => {
if (reviewMode() === mode) return;
setReviewMode(mode);
persist();
},
},
info: {
opened: infoOpened,
Expand Down
Loading