Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
149 changes: 120 additions & 29 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,17 +42,39 @@ 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;

Expand All @@ -65,39 +88,82 @@ export function ReviewPanel(props: ReviewPanelProps) {
}
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();
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();
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();
} catch (e) {
if (current !== version) return;
console.error("[ReviewPanel] Failed to load diffs:", e);
// Check if it's a git repo issue
setFiles([]);
await checkGitRepo();
} 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 +185,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 +201,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 +254,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 @@ -350,7 +416,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 +436,31 @@ 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">
<For each={REVIEW_MODES}>
{(item) => (
<button
type="button"
onClick={() => layout.review.setMode(item.value)}
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 +509,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 +526,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 +560,7 @@ export function ReviewPanel(props: ReviewPanelProps) {
>
{count() > 0
? "Select a file to view changes"
: "No changes in this session"}
: emptyText()}
</span>
</div>
}
Expand Down
23 changes: 23 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,10 @@ export function LayoutProvider(props: ParentProps) {
setReviewWidth(width);
persist();
},
setMode: (mode: ReviewMode) => {
setReviewMode(mode);
persist();
},
},
info: {
opened: infoOpened,
Expand Down
30 changes: 30 additions & 0 deletions app-prefixable/src/sdk/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3014,6 +3014,36 @@ export class Vcs extends HeyApiClient {
...params,
})
}

/**
* Get VCS diff
*
* Get git or branch diff for the current project directory.
*/
public diff<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
mode?: "git" | "branch"
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "mode" },
],
},
],
)
return (options?.client ?? this.client).get<SessionDiffResponses, unknown, ThrowOnError>({
url: "/vcs/diff",
...options,
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.

Vcs.diff() currently reuses SessionDiffResponses as its response type. Even if the payload shape is identical, this couples two unrelated endpoints and can be confusing for SDK consumers. Consider introducing a dedicated VcsDiffResponses type (e.g., 200: Array<FileDiff>) and using it here for clarity and stronger API semantics.

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 4a65d52. Added dedicated VcsDiffResponses/VcsDiffResponse in types.gen.ts and updated Vcs.diff() to use VcsDiffResponses instead of SessionDiffResponses for clearer API semantics.

...params,
})
}
Comment on lines +3018 to +3047
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.

sdk.gen.ts is marked as auto-generated. Adding Vcs.diff() directly here means the method will be lost the next time the SDK is regenerated, and it can also drift from the actual OpenAPI contract. If possible, update the OpenAPI spec (or the generation inputs) to include /vcs/diff and regenerate so the change is durable and the corresponding request/response types are emitted.

Suggested change
/**
* Get VCS diff
*
* Get git or branch diff for the current project directory.
*/
public diff<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
mode?: "git" | "branch"
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "mode" },
],
},
],
)
return (options?.client ?? this.client).get<SessionDiffResponses, unknown, ThrowOnError>({
url: "/vcs/diff",
...options,
...params,
})
}

Copilot uses AI. Check for mistakes.
}

export class Command extends HeyApiClient {
Expand Down