mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
refactor: unify agent sidebar into generic tabbed panel with Git sub-views (#22837)
## Summary Refactors the right-side panel in the Agents page into a generic tabbed container with a unified Git panel. ### Changes **Architecture** - `SidebarTabView` is now a generic tabbed container with no git-specific logic, ready for additional tabs - All Git content lives in a new `GitPanel` component with an internal Remote/Local segmented control **Git Panel** - Remote view: branch/PR diff via `FilesChangedPanel` - Local view: working tree changes with per-repo headers, commit & refresh actions - Split/unified diff toggle restored in the toolbar - `DiffStatBadge` rendered inside the Remote/Local segmented buttons (full-height, no rounding, inactive opacity 50%) **Visual polish** - Active/inactive/hover states match the sidebar agent selection styles (`bg-surface-quaternary/25`, `hover:bg-surface-tertiary/50`) - Inactive tab text uses `text-content-secondary` (not primary) - Tab button sizing fixed: `min-w-0` + `px-2` to prevent inflated width - Chat title centered via absolute positioning when panel is fullscreen - Polished empty states with boxed icons (`GitCompareArrowsIcon` for Remote, `FileDiffIcon` for Local) - Unified header styles between Remote and Local sections (both use `bg-surface-secondary` with consistent icon/text sizing) - Panel toggle always visible in top bar (not gated on having diff data) **Cleanup** - Removed dead code: `DiffStatsInline`, `computeDiffStats` export, `workingDiffStats` memo, `ChatDiffStatusResponse` import - Simplified `RepoChangesPanel` to a pure `DiffViewer` wrapper - Simplified `TopBar` to use a generic `panel` prop instead of diff-specific props
This commit is contained in:
@@ -70,6 +70,7 @@ import { AgentDetailTopBar } from "./AgentDetail/TopBar";
|
||||
import { useMessageWindow } from "./AgentDetail/useMessageWindow";
|
||||
import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
import { GitPanel } from "./GitPanel";
|
||||
import {
|
||||
getModelCatalogStatusMessage,
|
||||
getModelOptionsFromCatalog,
|
||||
@@ -77,7 +78,7 @@ import {
|
||||
hasConfiguredModelsInCatalog,
|
||||
} from "./modelOptions";
|
||||
import { RightPanel } from "./RightPanel";
|
||||
import { SidebarTabView } from "./SidebarTabView";
|
||||
import { type SidebarTab, SidebarTabView } from "./SidebarTabView";
|
||||
import { useFileAttachments } from "./useFileAttachments";
|
||||
import { useGitWatcher } from "./useGitWatcher";
|
||||
|
||||
@@ -989,7 +990,7 @@ const AgentDetail: FC = () => {
|
||||
workspace && workspaceAgent && sshConfigQuery.data?.hostname_suffix
|
||||
? `ssh ${workspaceAgent.name}.${workspace.name}.${workspace.owner_name}.${sshConfigQuery.data.hostname_suffix}`
|
||||
: undefined;
|
||||
const shouldShowSidebar = (hasDiffStatus || hasGitRepos) && showSidebarPanel;
|
||||
const shouldShowSidebar = showSidebarPanel;
|
||||
|
||||
const generateKeyMutation = useMutation({
|
||||
mutationFn: () => API.getApiKey(),
|
||||
@@ -1060,12 +1061,7 @@ const AgentDetail: FC = () => {
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
||||
{titleElement}
|
||||
<AgentDetailTopBar
|
||||
diff={{
|
||||
hasDiffStatus: false,
|
||||
diffStatus: undefined,
|
||||
hasGitRepos: false,
|
||||
gitRepoCount: 0,
|
||||
gitRepositories: new Map(),
|
||||
panel={{
|
||||
showSidebarPanel: false,
|
||||
onToggleSidebar: () => {},
|
||||
}}
|
||||
@@ -1110,7 +1106,7 @@ const AgentDetail: FC = () => {
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
</div>
|
||||
</div>{" "}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1139,12 +1135,7 @@ const AgentDetail: FC = () => {
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col">
|
||||
{titleElement}
|
||||
<AgentDetailTopBar
|
||||
diff={{
|
||||
hasDiffStatus: false,
|
||||
diffStatus: undefined,
|
||||
hasGitRepos: false,
|
||||
gitRepoCount: 0,
|
||||
gitRepositories: new Map(),
|
||||
panel={{
|
||||
showSidebarPanel: false,
|
||||
onToggleSidebar: () => {},
|
||||
}}
|
||||
@@ -1166,7 +1157,7 @@ const AgentDetail: FC = () => {
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center text-content-secondary">
|
||||
Chat not found
|
||||
</div>
|
||||
</div>{" "}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1190,12 +1181,7 @@ const AgentDetail: FC = () => {
|
||||
chatTitle={chatTitle}
|
||||
parentChat={parentChat}
|
||||
onOpenParentChat={(chatId) => navigate(`/agents/${chatId}`)}
|
||||
diff={{
|
||||
hasDiffStatus,
|
||||
diffStatus: diffStatusQuery.data,
|
||||
hasGitRepos,
|
||||
gitRepoCount: gitWatcher.repositories.size,
|
||||
gitRepositories: gitWatcher.repositories,
|
||||
panel={{
|
||||
showSidebarPanel,
|
||||
onToggleSidebar: () => setShowSidebarPanel((prev) => !prev),
|
||||
}}
|
||||
@@ -1289,30 +1275,37 @@ const AgentDetail: FC = () => {
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
>
|
||||
<SidebarTabView
|
||||
prTab={
|
||||
prNumber && agentId ? { prNumber, chatId: agentId } : undefined
|
||||
tabs={
|
||||
[
|
||||
(hasDiffStatus || hasGitRepos) && {
|
||||
id: "git",
|
||||
label: "Git",
|
||||
content: (
|
||||
<GitPanel
|
||||
prTab={
|
||||
prNumber && agentId
|
||||
? { prNumber, chatId: agentId }
|
||||
: undefined
|
||||
}
|
||||
repositories={gitWatcher.repositories}
|
||||
onRefresh={gitWatcher.refresh}
|
||||
onCommit={handleCommit}
|
||||
isExpanded={visualExpanded}
|
||||
remoteDiffStats={diffStatusQuery.data}
|
||||
chatInputRef={editing.chatInputRef}
|
||||
/>
|
||||
),
|
||||
},
|
||||
].filter(Boolean) as SidebarTab[]
|
||||
}
|
||||
repositories={gitWatcher.repositories}
|
||||
workspace={
|
||||
workspace
|
||||
? {
|
||||
name: workspace.name,
|
||||
ownerName: workspace.owner_name,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRefresh={gitWatcher.refresh}
|
||||
onCommit={handleCommit}
|
||||
onClose={() => setShowSidebarPanel(false)}
|
||||
isExpanded={visualExpanded}
|
||||
onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onToggleSidebarCollapsed={onToggleSidebarCollapsed}
|
||||
chatTitle={chatTitle}
|
||||
diffStatus={diffStatusQuery.data}
|
||||
chatInputRef={editing.chatInputRef}
|
||||
/>
|
||||
</RightPanel>
|
||||
</RightPanel>{" "}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ChatDiffStatusResponse } from "api/api";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import { AgentDetailTopBar } from "./TopBar";
|
||||
|
||||
const mockDiffStatus: ChatDiffStatusResponse = {
|
||||
chat_id: "chat-1",
|
||||
changes_requested: false,
|
||||
additions: 42,
|
||||
deletions: 7,
|
||||
changed_files: 5,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
chatTitle: "Build authentication feature",
|
||||
onOpenParentChat: () => {},
|
||||
diff: {
|
||||
hasDiffStatus: false,
|
||||
diffStatus: undefined,
|
||||
hasGitRepos: false,
|
||||
gitRepoCount: 0,
|
||||
gitRepositories: new Map(),
|
||||
panel: {
|
||||
showSidebarPanel: false,
|
||||
onToggleSidebar: () => {},
|
||||
},
|
||||
@@ -51,28 +37,9 @@ type Story = StoryObj<typeof AgentDetailTopBar>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithDiffStats: Story = {
|
||||
export const WithPanelOpen: Story = {
|
||||
args: {
|
||||
diff: {
|
||||
hasDiffStatus: true,
|
||||
diffStatus: mockDiffStatus,
|
||||
hasGitRepos: false,
|
||||
gitRepoCount: 0,
|
||||
gitRepositories: new Map(),
|
||||
showSidebarPanel: false,
|
||||
onToggleSidebar: () => {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDiffPanelOpen: Story = {
|
||||
args: {
|
||||
diff: {
|
||||
hasDiffStatus: true,
|
||||
diffStatus: mockDiffStatus,
|
||||
hasGitRepos: false,
|
||||
gitRepoCount: 0,
|
||||
gitRepositories: new Map(),
|
||||
panel: {
|
||||
showSidebarPanel: true,
|
||||
onToggleSidebar: () => {},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ChatDiffStatusResponse } from "api/api";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
@@ -26,14 +25,8 @@ import {
|
||||
import type { FC } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { DiffStatsInline } from "../DiffStats";
|
||||
|
||||
interface SidebarPanelState {
|
||||
hasDiffStatus: boolean;
|
||||
diffStatus: ChatDiffStatusResponse | undefined;
|
||||
hasGitRepos: boolean;
|
||||
gitRepoCount: number;
|
||||
gitRepositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
|
||||
showSidebarPanel: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
@@ -47,11 +40,11 @@ interface WorkspaceActions {
|
||||
sshCommand: string | undefined;
|
||||
}
|
||||
|
||||
interface AgentDetailTopBarProps {
|
||||
type AgentDetailTopBarProps = {
|
||||
chatTitle?: string;
|
||||
parentChat?: TypesGen.Chat;
|
||||
onOpenParentChat: (chatId: string) => void;
|
||||
diff: SidebarPanelState;
|
||||
panel: SidebarPanelState;
|
||||
workspace: WorkspaceActions;
|
||||
onArchiveAgent: () => void;
|
||||
onUnarchiveAgent: () => void;
|
||||
@@ -60,13 +53,13 @@ interface AgentDetailTopBarProps {
|
||||
isArchived?: boolean;
|
||||
isSidebarCollapsed: boolean;
|
||||
onToggleSidebarCollapsed: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
|
||||
chatTitle,
|
||||
parentChat,
|
||||
onOpenParentChat,
|
||||
diff,
|
||||
panel,
|
||||
workspace,
|
||||
onArchiveAgent,
|
||||
onUnarchiveAgent,
|
||||
@@ -122,16 +115,6 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
|
||||
<span className="truncate text-sm text-content-primary">
|
||||
{chatTitle}
|
||||
</span>
|
||||
{diff.hasDiffStatus &&
|
||||
diff.diffStatus &&
|
||||
!diff.showSidebarPanel && (
|
||||
<span className="ml-3">
|
||||
<DiffStatsInline
|
||||
status={diff.diffStatus}
|
||||
onClick={diff.onToggleSidebar}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{isArchived && (
|
||||
<span className="shrink-0 rounded bg-surface-tertiary px-1.5 py-0.5 text-xs text-content-secondary">
|
||||
Archived
|
||||
@@ -231,22 +214,20 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{(diff.hasDiffStatus || diff.hasGitRepos) && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={diff.onToggleSidebar}
|
||||
className="h-7 w-7 text-content-secondary hover:text-content-primary"
|
||||
aria-label="Toggle files changed"
|
||||
>
|
||||
{diff.showSidebarPanel ? (
|
||||
<PanelRightCloseIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpenIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>{" "}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={panel.onToggleSidebar}
|
||||
className="h-7 w-7 text-content-secondary hover:text-content-primary"
|
||||
aria-label="Toggle panel"
|
||||
>
|
||||
{panel.showSidebarPanel ? (
|
||||
<PanelRightCloseIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpenIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,35 +1,4 @@
|
||||
import type { ChatDiffStatusResponse } from "api/api";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
interface DiffStatsProps {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders +N / −N counters for diff additions and deletions.
|
||||
* Always renders both counters so that zero-line changes (e.g.
|
||||
* binary files like images) still display "+0 −0".
|
||||
*/
|
||||
const DiffStatNumbers: FC<DiffStatsProps> = ({
|
||||
additions,
|
||||
deletions,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 font-mono text-xs font-medium tabular-nums",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-green-700 dark:text-green-500">+{additions}</span>
|
||||
<span className="text-red-700 dark:text-red-400">−{deletions}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pill-styled diff stats badge with coloured backgrounds,
|
||||
@@ -43,7 +12,7 @@ export const DiffStatBadge: FC<{ additions: number; deletions: number }> = ({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex h-full items-center self-stretch overflow-hidden rounded-[calc(theme(borderRadius.md)-1px)] font-mono text-xs font-medium">
|
||||
<span className="inline-flex h-full items-center self-stretch overflow-hidden font-mono text-xs font-medium">
|
||||
{additions > 0 && (
|
||||
<span className="flex h-full items-center bg-green-100 dark:bg-green-950 px-1.5 text-green-700 dark:text-green-500">
|
||||
+{additions}
|
||||
@@ -57,31 +26,3 @@ export const DiffStatBadge: FC<{ additions: number; deletions: number }> = ({
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clickable inline diff stats shown in the top bar when the
|
||||
* diff panel is closed.
|
||||
*/
|
||||
export const DiffStatsInline: FC<{
|
||||
status: ChatDiffStatusResponse;
|
||||
onClick: () => void;
|
||||
}> = ({ status, onClick }) => {
|
||||
const additions = status.additions ?? 0;
|
||||
const deletions = status.deletions ?? 0;
|
||||
const hasChangedFiles = (status.changed_files ?? 0) > 0;
|
||||
|
||||
if (!hasChangedFiles && additions === 0 && deletions === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label="View diff statistics"
|
||||
className="inline-flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0 leading-none transition-opacity hover:opacity-80 outline-none"
|
||||
>
|
||||
<DiffStatNumbers additions={additions} deletions={deletions} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ const STICKY_HEADER_CSS = [
|
||||
].join(" ");
|
||||
|
||||
export type DiffStyle = "unified" | "split";
|
||||
export const DIFF_STYLE_KEY = "agents.diff-view-style";
|
||||
const DIFF_STYLE_KEY = "agents.diff-view-style";
|
||||
|
||||
export function loadDiffStyle(): DiffStyle {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -86,6 +86,10 @@ export function loadDiffStyle(): DiffStyle {
|
||||
return "unified";
|
||||
}
|
||||
|
||||
export function saveDiffStyle(style: DiffStyle): void {
|
||||
localStorage.setItem(DIFF_STYLE_KEY, style);
|
||||
}
|
||||
|
||||
/** Width of the file tree sidebar in pixels. */
|
||||
const FILE_TREE_WIDTH = 300;
|
||||
|
||||
@@ -609,7 +613,9 @@ export const DiffViewer: FC<DiffViewerProps> = ({
|
||||
className="flex h-full min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1 px-3 py-2">{headerLeft}</div>
|
||||
<div className="flex items-center gap-1 bg-surface-secondary px-3 py-2">
|
||||
{headerLeft}
|
||||
</div>
|
||||
{/* Diff contents */}
|
||||
{sortedFiles.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
|
||||
|
||||
@@ -796,7 +796,8 @@ export const FilesChangedPanel: FC<FilesChangedPanelProps> = ({
|
||||
className="flex h-full min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="flex items-center gap-3 bg-surface-secondary px-3 py-2">
|
||||
{" "}
|
||||
{pullRequestUrl && parsedPr ? (
|
||||
<a
|
||||
href={pullRequestUrl}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
import {
|
||||
CheckIcon,
|
||||
ColumnsIcon,
|
||||
FileDiffIcon,
|
||||
GitBranchIcon,
|
||||
GitCompareArrowsIcon,
|
||||
RefreshCwIcon,
|
||||
RowsIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import { DiffStatBadge } from "./DiffStats";
|
||||
import { type DiffStyle, loadDiffStyle, saveDiffStyle } from "./DiffViewer";
|
||||
import { FilesChangedPanel } from "./FilesChangedPanel";
|
||||
import { RepoChangesPanel } from "./RepoChangesPanel";
|
||||
|
||||
type GitView = "remote" | "local";
|
||||
|
||||
interface DiffStats {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
interface GitPanelProps {
|
||||
/** PR tab data. Omitted if no PR is associated. */
|
||||
prTab?: {
|
||||
prNumber: number;
|
||||
chatId: string;
|
||||
};
|
||||
/** Repository data from git watcher. */
|
||||
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
|
||||
/** Callback to send a refresh to the git watcher. */
|
||||
onRefresh: () => void;
|
||||
/** Called when the user clicks the Commit button in any repo tab. */
|
||||
onCommit: (repoRoot: string) => void;
|
||||
/** Whether the panel is in expanded/fullscreen mode. */
|
||||
isExpanded?: boolean;
|
||||
/** Diff stats for the remote/branch view. */
|
||||
remoteDiffStats?: DiffStats;
|
||||
/** Diff stats for the local/working tree view. */
|
||||
localDiffStats?: DiffStats;
|
||||
/** Ref to the chat input, forwarded to FilesChangedPanel. */
|
||||
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
||||
}
|
||||
|
||||
function repoTabLabel(repoRoot: string): string {
|
||||
const segments = repoRoot.split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] ?? repoRoot;
|
||||
}
|
||||
|
||||
export const GitPanel: FC<GitPanelProps> = ({
|
||||
prTab,
|
||||
repositories,
|
||||
onRefresh,
|
||||
onCommit,
|
||||
isExpanded,
|
||||
remoteDiffStats,
|
||||
localDiffStats,
|
||||
chatInputRef,
|
||||
}) => {
|
||||
const [view, setView] = useState<GitView>("remote");
|
||||
|
||||
// Diff style is managed here for the local view only.
|
||||
// FilesChangedPanel manages its own diff style internally.
|
||||
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
|
||||
|
||||
const hasRemoteStats =
|
||||
!!remoteDiffStats &&
|
||||
(remoteDiffStats.additions > 0 || remoteDiffStats.deletions > 0);
|
||||
const hasLocalStats =
|
||||
!!localDiffStats &&
|
||||
(localDiffStats.additions > 0 || localDiffStats.deletions > 0);
|
||||
|
||||
const handleDiffStyleChange = useCallback((style: DiffStyle) => {
|
||||
saveDiffStyle(style);
|
||||
setDiffStyle(style);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1.5">
|
||||
{/* Remote / Local segmented control */}
|
||||
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("remote")}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 border-none font-medium transition-colors",
|
||||
view === "remote"
|
||||
? "bg-surface-quaternary/25 text-content-primary"
|
||||
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
|
||||
hasRemoteStats ? "pl-3 pr-0" : "px-3",
|
||||
)}
|
||||
>
|
||||
Remote
|
||||
{hasRemoteStats && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full items-center self-stretch transition-opacity",
|
||||
view !== "remote" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<DiffStatBadge
|
||||
additions={remoteDiffStats.additions}
|
||||
deletions={remoteDiffStats.deletions}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("local")}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 border-0 border-l border-solid border-border-default font-medium transition-colors",
|
||||
view === "local"
|
||||
? "bg-surface-quaternary/25 text-content-primary"
|
||||
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
|
||||
hasLocalStats ? "pl-3 pr-0" : "px-3",
|
||||
)}
|
||||
>
|
||||
Local
|
||||
{hasLocalStats && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full items-center self-stretch transition-opacity",
|
||||
view !== "local" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<DiffStatBadge
|
||||
additions={localDiffStats.additions}
|
||||
deletions={localDiffStats.deletions}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{/* Split / Unified toggle — only shown for local view since
|
||||
FilesChangedPanel has its own toggle built in. */}
|
||||
{view === "local" && (
|
||||
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDiffStyleChange("unified")}
|
||||
aria-label="Unified diff"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center border-none px-1.5 transition-colors",
|
||||
diffStyle === "unified"
|
||||
? "bg-surface-quaternary/25 text-content-primary"
|
||||
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
|
||||
)}
|
||||
>
|
||||
<RowsIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDiffStyleChange("split")}
|
||||
aria-label="Split diff"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center border-0 border-l border-solid border-border-default px-1.5 transition-colors",
|
||||
diffStyle === "split"
|
||||
? "bg-surface-quaternary/25 text-content-primary"
|
||||
: "bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
|
||||
)}
|
||||
>
|
||||
<ColumnsIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-h-0 flex-1">
|
||||
{view === "remote" ? (
|
||||
<RemoteContent
|
||||
prTab={prTab}
|
||||
isExpanded={isExpanded}
|
||||
chatInputRef={chatInputRef}
|
||||
/>
|
||||
) : (
|
||||
<LocalContent
|
||||
repositories={repositories}
|
||||
onRefresh={onRefresh}
|
||||
onCommit={onCommit}
|
||||
isExpanded={isExpanded}
|
||||
diffStyle={diffStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Remote view (branch/PR diff)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const RemoteContent: FC<{
|
||||
prTab?: { prNumber: number; chatId: string };
|
||||
isExpanded?: boolean;
|
||||
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
||||
}> = ({ prTab, isExpanded, chatInputRef }) => {
|
||||
if (!prTab) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 flex size-10 items-center justify-center rounded-lg border border-solid border-border-default bg-surface-secondary">
|
||||
<GitCompareArrowsIcon className="size-5 text-content-secondary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-primary">
|
||||
No pushed changes yet
|
||||
</p>
|
||||
<p className="mt-1 max-w-52 text-xs text-content-secondary">
|
||||
Once commits are pushed, the branch diff will appear here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilesChangedPanel
|
||||
chatId={prTab.chatId}
|
||||
isExpanded={isExpanded}
|
||||
chatInputRef={chatInputRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Local view (working tree changes)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const LocalContent: FC<{
|
||||
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
|
||||
onRefresh: () => void;
|
||||
onCommit: (repoRoot: string) => void;
|
||||
isExpanded?: boolean;
|
||||
diffStyle: DiffStyle;
|
||||
}> = ({ repositories, onRefresh, onCommit, isExpanded, diffStyle }) => {
|
||||
const repoEntries = useMemo(
|
||||
() =>
|
||||
Array.from(repositories.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
||||
[repositories],
|
||||
);
|
||||
|
||||
if (repoEntries.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 flex size-10 items-center justify-center rounded-lg border border-solid border-border-default bg-surface-secondary">
|
||||
<FileDiffIcon className="size-5 text-content-secondary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-content-primary">
|
||||
No uncommitted changes
|
||||
</p>
|
||||
<p className="mt-1 max-w-52 text-xs text-content-secondary">
|
||||
Local file modifications will appear here as you edit.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col">
|
||||
{repoEntries.map(([repoRoot, repo], index) => {
|
||||
const showSeparator = index > 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={repoRoot}
|
||||
className={cn(
|
||||
showSeparator &&
|
||||
"border-0 border-t border-solid border-border-default",
|
||||
)}
|
||||
>
|
||||
<RepoHeader
|
||||
repoRoot={repoRoot}
|
||||
repo={repo}
|
||||
onRefresh={onRefresh}
|
||||
onCommit={() => onCommit(repoRoot)}
|
||||
/>
|
||||
<RepoChangesPanel
|
||||
repo={repo}
|
||||
isExpanded={isExpanded}
|
||||
diffStyle={diffStyle}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Repo header for local view
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const RepoHeader: FC<{
|
||||
repoRoot: string;
|
||||
repo: WorkspaceAgentRepoChanges;
|
||||
onRefresh: () => void;
|
||||
onCommit: () => void;
|
||||
}> = ({ repoRoot, repo, onRefresh, onCommit }) => {
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
|
||||
const handleRefresh = useCallback(() => {
|
||||
onRefresh();
|
||||
setSpinning(true);
|
||||
clearTimeout(spinTimerRef.current);
|
||||
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
|
||||
}, [onRefresh]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-surface-secondary px-3 py-2">
|
||||
{/* Repo identity */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<GitBranchIcon className="size-3.5 shrink-0 text-content-secondary" />
|
||||
<span className="truncate text-sm font-medium text-content-primary">
|
||||
{repo.branch?.trim() || repoTabLabel(repoRoot)}
|
||||
</span>
|
||||
{repo.branch?.trim() && (
|
||||
<span className="truncate text-xs text-content-secondary">
|
||||
{repoTabLabel(repoRoot)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onCommit}
|
||||
disabled={!repo.unified_diff}
|
||||
className="h-7 gap-1.5 border border-transparent bg-surface-invert-primary px-2 text-xs text-content-invert hover:bg-surface-invert-secondary active:opacity-80"
|
||||
>
|
||||
<CheckIcon className="size-3" />
|
||||
Commit
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
className="h-7 w-7 text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
spinning && "motion-safe:animate-spin-once",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||
import { fn } from "storybook/test";
|
||||
import { RepoChangesPanel } from "./RepoChangesPanel";
|
||||
|
||||
const sampleDiff = `--- a/src/main.ts
|
||||
@@ -35,8 +34,6 @@ const meta: Meta<typeof RepoChangesPanel> = {
|
||||
component: RepoChangesPanel,
|
||||
args: {
|
||||
repo: baseRepo,
|
||||
onRefresh: fn(),
|
||||
onCommit: fn(),
|
||||
diffStyle: "unified",
|
||||
},
|
||||
};
|
||||
@@ -60,42 +57,3 @@ export const SplitDiffStyle: Story = {
|
||||
diffStyle: "split",
|
||||
},
|
||||
};
|
||||
|
||||
export const LongBranchName: Story = {
|
||||
args: {
|
||||
repo: {
|
||||
...baseRepo,
|
||||
branch:
|
||||
"feature/TICKET-12345-implement-very-long-branch-name-for-testing-truncation-behavior",
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 400 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const DeepRepoPath: Story = {
|
||||
args: {
|
||||
repo: {
|
||||
...baseRepo,
|
||||
repo_root: "/home/coder/workspaces/my-org/services/project",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyBranchName: Story = {
|
||||
args: {
|
||||
repo: {
|
||||
...baseRepo,
|
||||
branch: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyFiles: Story = {};
|
||||
|
||||
export const UntrackedFiles: Story = {};
|
||||
|
||||
@@ -1,56 +1,19 @@
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
CheckIcon,
|
||||
FolderIcon,
|
||||
GitBranchIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { type DiffStyle, DiffViewer } from "./DiffViewer";
|
||||
|
||||
interface RepoChangesPanelProps {
|
||||
repo: WorkspaceAgentRepoChanges;
|
||||
onRefresh: () => void;
|
||||
onCommit: () => void;
|
||||
isExpanded?: boolean;
|
||||
diffStyle: DiffStyle;
|
||||
}
|
||||
|
||||
function repoParentPath(repoRoot: string): string {
|
||||
const lastSlash = repoRoot.lastIndexOf("/");
|
||||
if (lastSlash === -1) {
|
||||
return "";
|
||||
}
|
||||
return repoRoot.slice(0, lastSlash + 1);
|
||||
}
|
||||
|
||||
export const RepoChangesPanel: FC<RepoChangesPanelProps> = ({
|
||||
repo,
|
||||
onRefresh,
|
||||
onCommit,
|
||||
isExpanded,
|
||||
diffStyle,
|
||||
}) => {
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const spinTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => () => clearTimeout(spinTimerRef.current), []);
|
||||
const handleRefresh = useCallback(() => {
|
||||
onRefresh();
|
||||
setSpinning(true);
|
||||
clearTimeout(spinTimerRef.current);
|
||||
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
|
||||
}, [onRefresh]);
|
||||
|
||||
const parsedFiles = useMemo(() => {
|
||||
const diff = repo.unified_diff;
|
||||
if (!diff) {
|
||||
@@ -64,52 +27,8 @@ export const RepoChangesPanel: FC<RepoChangesPanelProps> = ({
|
||||
}
|
||||
}, [repo.unified_diff]);
|
||||
|
||||
const hasChanges = parsedFiles.length > 0;
|
||||
const parentPath = repoParentPath(repo.repo_root);
|
||||
|
||||
return (
|
||||
<DiffViewer
|
||||
headerLeft={
|
||||
<div className="flex w-full min-w-0 items-center gap-1.5">
|
||||
{parentPath && (
|
||||
<div className="flex h-7 min-w-0 items-center gap-1 rounded-md border border-solid border-border-default px-1.5 text-xs text-content-secondary">
|
||||
<FolderIcon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{parentPath}</span>
|
||||
</div>
|
||||
)}
|
||||
{repo.branch?.trim() && (
|
||||
<div className="flex h-7 min-w-0 items-center gap-1 rounded-md border border-solid border-border-default px-1.5 text-xs text-content-secondary">
|
||||
<GitBranchIcon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{repo.branch}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onCommit}
|
||||
disabled={!hasChanges}
|
||||
className="h-7 gap-1.5 border border-transparent bg-surface-invert-primary px-2 text-xs text-content-invert hover:bg-surface-invert-secondary active:opacity-80"
|
||||
>
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
Commit
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh"
|
||||
className="h-7 w-7 text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
spinning && "motion-safe:animate-spin-once",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
parsedFiles={parsedFiles}
|
||||
isExpanded={isExpanded}
|
||||
emptyMessage="No file changes."
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||
import { fn } from "storybook/test";
|
||||
import type { SidebarTab } from "./SidebarTabView";
|
||||
import { SidebarTabView } from "./SidebarTabView";
|
||||
|
||||
const sampleDiff = `--- a/src/index.ts
|
||||
+++ b/src/index.ts
|
||||
@@ -1,3 +1,5 @@
|
||||
+import { init } from "./init";
|
||||
+
|
||||
const main = () => {
|
||||
console.log("hello");
|
||||
};
|
||||
`;
|
||||
const makePanelContent = (label: string) => (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-content-secondary">
|
||||
Content for {label}
|
||||
</div>
|
||||
);
|
||||
|
||||
const makeRepo = (
|
||||
name: string,
|
||||
overrides?: Partial<WorkspaceAgentRepoChanges>,
|
||||
): WorkspaceAgentRepoChanges => ({
|
||||
repo_root: `/home/coder/${name}`,
|
||||
branch: "main",
|
||||
remote_origin: `https://github.com/coder/${name}.git`,
|
||||
unified_diff: sampleDiff,
|
||||
...overrides,
|
||||
});
|
||||
const makeBadge = (additions: number, deletions: number) => (
|
||||
<span className="inline-flex h-full items-center self-stretch overflow-hidden font-mono text-xs font-medium">
|
||||
{additions > 0 && (
|
||||
<span className="flex h-full items-center bg-green-100 px-1.5 text-green-700 dark:bg-green-950 dark:text-green-500">
|
||||
+{additions}
|
||||
</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="flex h-full items-center bg-red-100 px-1.5 text-red-700 dark:bg-red-950 dark:text-red-400">
|
||||
−{deletions}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const gitTab: SidebarTab = {
|
||||
id: "git",
|
||||
label: "Git",
|
||||
badge: makeBadge(42, 7),
|
||||
content: makePanelContent("Git"),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SidebarTabView> = {
|
||||
title: "pages/AgentsPage/SidebarTabView",
|
||||
component: SidebarTabView,
|
||||
args: {
|
||||
workspace: { name: "my-workspace", ownerName: "admin" },
|
||||
onRefresh: fn(),
|
||||
onCommit: fn(),
|
||||
tabs: [gitTab],
|
||||
isExpanded: false,
|
||||
onToggleExpanded: fn(),
|
||||
},
|
||||
@@ -45,92 +50,47 @@ const meta: Meta<typeof SidebarTabView> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SidebarTabView>;
|
||||
|
||||
export const PROnly: Story = {
|
||||
export const GitWithBadge: Story = {};
|
||||
|
||||
export const GitNoBadge: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 42, chatId: "chat-1" },
|
||||
repositories: new Map(),
|
||||
tabs: [{ ...gitTab, badge: undefined }],
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleRepo: Story = {
|
||||
export const MultipleTabs: Story = {
|
||||
args: {
|
||||
prTab: undefined,
|
||||
repositories: new Map([["/home/coder/project", makeRepo("project")]]),
|
||||
},
|
||||
};
|
||||
|
||||
export const PRAndRepos: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 123, chatId: "chat-2" },
|
||||
repositories: new Map([
|
||||
["/home/coder/frontend", makeRepo("frontend")],
|
||||
[
|
||||
"/home/coder/backend",
|
||||
makeRepo("backend", {
|
||||
branch: "feat/api",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyRepos: Story = {
|
||||
args: {
|
||||
prTab: undefined,
|
||||
repositories: new Map(
|
||||
["alpha", "bravo", "charlie", "delta", "echo"].map((name) => [
|
||||
`/home/coder/${name}`,
|
||||
makeRepo(name),
|
||||
]),
|
||||
),
|
||||
tabs: [
|
||||
gitTab,
|
||||
{ id: "preview", label: "Preview", content: makePanelContent("Preview") },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
args: {
|
||||
prTab: undefined,
|
||||
repositories: new Map(),
|
||||
tabs: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const SplitDiffMode: Story = {
|
||||
export const ExpandedWithTitle: Story = {
|
||||
args: {
|
||||
prTab: undefined,
|
||||
repositories: new Map([["/home/coder/project", makeRepo("project")]]),
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpandedWithDiffToggle: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 42, chatId: "chat-1" },
|
||||
repositories: new Map([["/home/coder/project", makeRepo("project")]]),
|
||||
tabs: [gitTab],
|
||||
isExpanded: true,
|
||||
chatTitle: "Fix authentication bug",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ height: 600, width: 900 }}>
|
||||
<div style={{ height: 500, width: 900 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const WithDiffStats: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 99, chatId: "chat-3" },
|
||||
repositories: new Map([
|
||||
["/home/coder/frontend", makeRepo("frontend")],
|
||||
["/home/coder/backend", makeRepo("backend", { branch: "feat/api" })],
|
||||
]),
|
||||
diffStatus: { additions: 150, deletions: 42 },
|
||||
},
|
||||
};
|
||||
|
||||
export const NarrowPanel: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 42, chatId: "chat-1" },
|
||||
repositories: new Map([["/home/coder/project", makeRepo("project")]]),
|
||||
tabs: [gitTab],
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
|
||||
@@ -1,50 +1,39 @@
|
||||
import { parsePatchFiles } from "@pierre/diffs";
|
||||
import type { WorkspaceAgentRepoChanges } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Columns2Icon,
|
||||
MaximizeIcon,
|
||||
MinimizeIcon,
|
||||
PanelLeftIcon,
|
||||
Rows3Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import type { ChatMessageInputRef } from "./AgentChatInput";
|
||||
import { DiffStatBadge } from "./DiffStats";
|
||||
import { DIFF_STYLE_KEY, type DiffStyle, loadDiffStyle } from "./DiffViewer";
|
||||
import { FilesChangedPanel } from "./FilesChangedPanel";
|
||||
import { RepoChangesPanel } from "./RepoChangesPanel";
|
||||
|
||||
/** A single tab definition for the sidebar panel. */
|
||||
export interface SidebarTab {
|
||||
id: string;
|
||||
/** Label shown in the tab button. */
|
||||
label: string;
|
||||
/** Optional icon shown before the label. */
|
||||
icon?: ReactNode;
|
||||
/** Optional badge shown after the label (e.g. diff stats). */
|
||||
badge?: ReactNode;
|
||||
/** The content to render when this tab is active. */
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface SidebarTabViewProps {
|
||||
/** PR tab data. Omitted if no PR is associated. */
|
||||
prTab?: {
|
||||
prNumber: number;
|
||||
chatId: string;
|
||||
};
|
||||
/** Repository tabs from git watcher. */
|
||||
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
|
||||
/** Workspace info for the header. */
|
||||
workspace?: {
|
||||
name: string;
|
||||
ownerName: string;
|
||||
};
|
||||
/** Callback to send a refresh to the git watcher. */
|
||||
onRefresh: () => void;
|
||||
/** Called when the user clicks the Commit button in any repo tab. */
|
||||
onCommit: (repoRoot: string) => void;
|
||||
/** The tabs to display. */
|
||||
tabs: SidebarTab[];
|
||||
/** Whether the panel is in expanded/fullscreen mode. */
|
||||
isExpanded: boolean;
|
||||
/** Callback to toggle expanded state. */
|
||||
@@ -55,12 +44,8 @@ interface SidebarTabViewProps {
|
||||
onToggleSidebarCollapsed?: () => void;
|
||||
/** Shown in center when expanded. */
|
||||
chatTitle?: string;
|
||||
/** PR diff stats for the PR tab. */
|
||||
diffStatus?: { additions?: number; deletions?: number };
|
||||
/** Callback to close the panel (used on mobile). */
|
||||
onClose?: () => void;
|
||||
/** Ref to the chat input, forwarded to FilesChangedPanel. */
|
||||
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
||||
}
|
||||
|
||||
/** How far (px) each chevron click scrolls the tab strip. */
|
||||
@@ -119,98 +104,34 @@ function useTabScroll() {
|
||||
return { ref, canScrollLeft, canScrollRight, scrollLeft, scrollRight };
|
||||
}
|
||||
|
||||
function repoTabLabel(repoRoot: string): string {
|
||||
const segments = repoRoot.split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] ?? repoRoot;
|
||||
}
|
||||
|
||||
function computeDiffStats(unifiedDiff: string | undefined): {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
} {
|
||||
if (!unifiedDiff) return { additions: 0, deletions: 0 };
|
||||
try {
|
||||
const patches = parsePatchFiles(unifiedDiff);
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
for (const patch of patches) {
|
||||
for (const file of patch.files) {
|
||||
for (const hunk of file.hunks) {
|
||||
additions += hunk.additionLines;
|
||||
deletions += hunk.deletionLines;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { additions, deletions };
|
||||
} catch {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
||||
prTab,
|
||||
repositories,
|
||||
onRefresh,
|
||||
onCommit,
|
||||
tabs,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
chatTitle,
|
||||
diffStatus,
|
||||
onClose,
|
||||
chatInputRef,
|
||||
}) => {
|
||||
const tabIdPrefix = useId();
|
||||
const repoEntries = Array.from(repositories.entries()).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(
|
||||
tabs.length > 0 ? tabs[0].id : null,
|
||||
);
|
||||
|
||||
const hasPR = Boolean(prTab);
|
||||
const hasRepos = repoEntries.length > 0;
|
||||
// Derive the effective tab. Fall back to the first tab if
|
||||
// the stored activeTabId no longer matches any tab in the list.
|
||||
const effectiveTabId =
|
||||
activeTabId !== null && tabs.some((t) => t.id === activeTabId)
|
||||
? activeTabId
|
||||
: tabs.length > 0
|
||||
? tabs[0].id
|
||||
: null;
|
||||
|
||||
// Default active tab: PR if present, otherwise first repo.
|
||||
const defaultTab = hasPR
|
||||
? "pr"
|
||||
: repoEntries.length > 0
|
||||
? repoEntries[0][0]
|
||||
: null;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>(defaultTab);
|
||||
|
||||
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
|
||||
const handleSetDiffStyle = useCallback((style: DiffStyle) => {
|
||||
setDiffStyle(style);
|
||||
localStorage.setItem(DIFF_STYLE_KEY, style);
|
||||
}, []);
|
||||
|
||||
// Derive the effective tab inline to avoid a one-frame flash when
|
||||
// activeTab is stale or null but a valid default exists.
|
||||
const effectiveTab =
|
||||
activeTab !== null &&
|
||||
(activeTab === "pr" ? hasPR : repositories.has(activeTab))
|
||||
? activeTab
|
||||
: defaultTab;
|
||||
|
||||
// Compute diff stats for all repo tabs and cache them.
|
||||
const repoDiffStats = useMemo(() => {
|
||||
const statsMap = new Map<
|
||||
string,
|
||||
{ additions: number; deletions: number }
|
||||
>();
|
||||
for (const [repoRoot, repo] of repoEntries) {
|
||||
statsMap.set(repoRoot, computeDiffStats(repo.unified_diff));
|
||||
}
|
||||
return statsMap;
|
||||
}, [repoEntries]);
|
||||
|
||||
const prDiffAdditions = diffStatus?.additions ?? 0;
|
||||
const prDiffDeletions = diffStatus?.deletions ?? 0;
|
||||
const hasPrDiffStats = prDiffAdditions > 0 || prDiffDeletions > 0;
|
||||
const activeTab = tabs.find((t) => t.id === effectiveTabId) ?? null;
|
||||
|
||||
const tabScroll = useTabScroll();
|
||||
|
||||
if (!hasPR && !hasRepos) {
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-surface-primary">
|
||||
{/* Tab bar – always visible for the expand button. */}
|
||||
@@ -247,7 +168,7 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-content-secondary">
|
||||
No changes to display.
|
||||
No panels available.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -258,7 +179,7 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
||||
{/* Tab bar */}
|
||||
<div
|
||||
role="tablist"
|
||||
className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1"
|
||||
className="relative flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1"
|
||||
>
|
||||
{/* Sidebar toggle – only when expanded and sidebar is collapsed */}
|
||||
{isExpanded && isSidebarCollapsed && onToggleSidebarCollapsed && (
|
||||
@@ -288,49 +209,36 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
||||
ref={tabScroll.ref}
|
||||
className="flex w-full items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{hasPR && prTab && (
|
||||
<Button
|
||||
id={`${tabIdPrefix}-tab-pr`}
|
||||
role="tab"
|
||||
aria-selected={effectiveTab === "pr"}
|
||||
onClick={() => setActiveTab("pr")}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className={cn(
|
||||
"shrink-0 h-6 px-3 gap-3 py-0 bg-surface-primary",
|
||||
effectiveTab === "pr" && "bg-surface-tertiary",
|
||||
hasPrDiffStats && "pr-0",
|
||||
)}
|
||||
>
|
||||
#{prTab.prNumber}
|
||||
<DiffStatBadge
|
||||
additions={prDiffAdditions}
|
||||
deletions={prDiffDeletions}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{repoEntries.map(([repoRoot]) => {
|
||||
const stats = repoDiffStats.get(repoRoot);
|
||||
const additions = stats?.additions ?? 0;
|
||||
const deletions = stats?.deletions ?? 0;
|
||||
const hasStats = additions > 0 || deletions > 0;
|
||||
{tabs.map((tab) => {
|
||||
const isActive = effectiveTabId === tab.id;
|
||||
return (
|
||||
<Button
|
||||
key={repoRoot}
|
||||
id={`${tabIdPrefix}-tab-${repoRoot}`}
|
||||
key={tab.id}
|
||||
id={`${tabIdPrefix}-tab-${tab.id}`}
|
||||
role="tab"
|
||||
aria-selected={effectiveTab === repoRoot}
|
||||
onClick={() => setActiveTab(repoRoot)}
|
||||
aria-selected={isActive}
|
||||
onClick={() => setActiveTabId(tab.id)}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className={cn(
|
||||
"shrink-0 h-6 px-3 gap-3 py-0 bg-surface-primary",
|
||||
effectiveTab === repoRoot && "bg-surface-tertiary",
|
||||
hasStats && "pr-0",
|
||||
"shrink-0 h-6 min-w-0 gap-3 px-2 py-0 bg-surface-primary text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary",
|
||||
isActive &&
|
||||
"bg-surface-quaternary/25 hover:bg-surface-quaternary/50",
|
||||
tab.badge && "pr-0",
|
||||
)}
|
||||
>
|
||||
{repoTabLabel(repoRoot)}
|
||||
<DiffStatBadge additions={additions} deletions={deletions} />
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
{tab.badge && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-full items-center self-stretch transition-opacity",
|
||||
!isActive && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
@@ -347,40 +255,13 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{/* Center: chat title when expanded */}
|
||||
<div className="min-w-0 shrink-0 text-center">
|
||||
{isExpanded && chatTitle && (
|
||||
<span className="truncate text-sm text-content-primary">
|
||||
{isExpanded && chatTitle && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className="truncate px-24 text-sm text-content-primary">
|
||||
{chatTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Diff style toggle */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant={diffStyle === "unified" ? "outline" : "subtle"}
|
||||
size="lg"
|
||||
onClick={() => handleSetDiffStyle("unified")}
|
||||
className={cn(
|
||||
"min-w-0 h-6 px-2 py-0",
|
||||
diffStyle === "unified" && "bg-surface-secondary",
|
||||
)}
|
||||
aria-label="Unified diff view"
|
||||
>
|
||||
<Rows3Icon className="!p-0 !size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={diffStyle === "split" ? "outline" : "subtle"}
|
||||
size="lg"
|
||||
onClick={() => handleSetDiffStyle("split")}
|
||||
className={cn(
|
||||
"min-w-0 h-6 px-2 py-0",
|
||||
diffStyle === "split" && "bg-surface-secondary",
|
||||
)}
|
||||
aria-label="Split diff view"
|
||||
>
|
||||
<Columns2Icon className="!p-0 !size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Right side: close (mobile) / expand (desktop) */}
|
||||
{onClose && (
|
||||
<Button
|
||||
@@ -407,25 +288,11 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
|
||||
<div
|
||||
role="tabpanel"
|
||||
aria-labelledby={
|
||||
effectiveTab ? `${tabIdPrefix}-tab-${effectiveTab}` : undefined
|
||||
effectiveTabId ? `${tabIdPrefix}-tab-${effectiveTabId}` : undefined
|
||||
}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
{effectiveTab === "pr" && prTab ? (
|
||||
<FilesChangedPanel
|
||||
chatId={prTab.chatId}
|
||||
isExpanded={isExpanded}
|
||||
chatInputRef={chatInputRef}
|
||||
/>
|
||||
) : effectiveTab && repositories.has(effectiveTab) ? (
|
||||
<RepoChangesPanel
|
||||
repo={repositories.get(effectiveTab)!}
|
||||
onRefresh={onRefresh}
|
||||
onCommit={() => onCommit(effectiveTab)}
|
||||
isExpanded={isExpanded}
|
||||
diffStyle={diffStyle}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab?.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user