diff --git a/site/src/@types/storybook.d.ts b/site/src/@types/storybook.d.ts index 599324a291..f15e7761ad 100644 --- a/site/src/@types/storybook.d.ts +++ b/site/src/@types/storybook.d.ts @@ -20,7 +20,7 @@ declare module "@storybook/react-vite" { showOrganizations?: boolean; organizations?: Organization[]; queries?: { key: QueryKey; data: unknown; isError?: boolean }[]; - webSocket?: WebSocketEvent[]; + webSocket?: WebSocketEvent[] | Record; user?: User; permissions?: Partial; deploymentValues?: DeploymentValues; diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7506e10747..b8849444a9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -159,6 +159,10 @@ export const watchChats = (): OneWayWebSocket => { }); }; +export const watchChatGit = (chatId: string): WebSocket => { + return createWebSocket(`/api/experimental/chats/${chatId}/git/watch`); +}; + export const watchAgentContainers = ( agentId: string, ): OneWayWebSocket => { diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index fbf58498b3..44b62fda13 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -474,22 +474,24 @@ export const StreamedSubagentTitle: Story = { }, { diffUrl: undefined }, ), - webSocket: [ - { - event: "message", - data: wrapSSE({ - type: "message_part", - message_part: { - part: { - type: "tool-call", - tool_call_id: "tool-subagent-stream-1", - tool_name: "spawn_agent", - args_delta: '{"title":"Streamed Child"', + webSocket: { + "/chats/": [ + { + event: "message", + data: wrapSSE({ + type: "message_part", + message_part: { + part: { + type: "tool-call", + tool_call_id: "tool-subagent-stream-1", + tool_name: "spawn_agent", + args_delta: '{"title":"Streamed Child"', + }, }, - }, - }), - }, - ], + }), + }, + ], + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -503,6 +505,252 @@ export const StreamedSubagentTitle: Story = { }, }; +/** + * Sidebar with a PR tab and two git repo tabs. The git watcher receives + * repo data via the mocked WebSocket, and the diff-status query provides + * the PR tab. + */ +export const SidebarWithPRAndRepos: Story = { + parameters: { + queries: buildQueries( + { + chat: { + id: CHAT_ID, + ...baseChatFields, + title: "Full sidebar demo", + status: "completed", + }, + messages: [], + queued_messages: [], + }, + { diffUrl: "https://github.com/coder/coder/pull/456" }, + ), + webSocket: { + "/git/watch": [ + { + event: "message", + data: JSON.stringify({ + type: "changes", + scanned_at: new Date().toISOString(), + repositories: [ + { + repo_root: "/home/coder/frontend", + branch: "feat/ui-overhaul", + remote_origin: "https://github.com/coder/frontend.git", + unified_diff: [ + "diff --git a/src/index.ts b/src/index.ts", + "index aaa1111..bbb2222 100644", + "--- a/src/index.ts", + "+++ b/src/index.ts", + "@@ -1,4 +1,6 @@", + ' import { render } from "react-dom";', + '+import { ThemeProvider } from "./theme";', + "+", + " const root = document.getElementById('root');", + "-render(, root);", + "+render(, root);", + "", + "diff --git a/src/old-utils.ts b/src/old-utils.ts", + "deleted file mode 100644", + "index fff6666..0000000", + "--- a/src/old-utils.ts", + "+++ /dev/null", + "@@ -1,3 +0,0 @@", + "-export const deprecatedHelper = () => {};", + "-export const oldFormat = () => {};", + "-export const legacyParse = () => {};", + "", + "diff --git a/src/helpers.ts b/src/utils.ts", + "similarity index 90%", + "rename from src/helpers.ts", + "rename to src/utils.ts", + "index aaa1111..bbb2222 100644", + "--- a/src/helpers.ts", + "+++ b/src/utils.ts", + "", + "diff --git a/src/components/Button.tsx b/src/components/Button.tsx", + "new file mode 100644", + "index 0000000..abc1234", + "--- /dev/null", + "+++ b/src/components/Button.tsx", + "@@ -0,0 +1,8 @@", + '+import { type FC } from "react";', + "+", + "+interface ButtonProps {", + "+ children: React.ReactNode;", + "+}", + "+", + "+export const Button: FC = ({ children }) => {", + '+ return ;', + "+};", + ].join("\n"), + }, + { + repo_root: "/home/coder/backend", + branch: "feat/api-v2", + remote_origin: "https://github.com/coder/backend.git", + unified_diff: [ + "diff --git a/cmd/server/main.go b/cmd/server/main.go", + "index ddd4444..eee5555 100644", + "--- a/cmd/server/main.go", + "+++ b/cmd/server/main.go", + "@@ -15,3 +15,7 @@ func main() {", + ' srv := &http.Server{Addr: ":8080"}', + "+ srv.ReadTimeout = 30 * time.Second", + "+ srv.WriteTimeout = 30 * time.Second", + "+", + '+ log.Println("starting server on :8080")', + " log.Fatal(srv.ListenAndServe())", + " }", + "", + "diff --git a/internal/old-handler.go b/internal/old-handler.go", + "deleted file mode 100644", + "index ccc3333..0000000", + "--- a/internal/old-handler.go", + "+++ /dev/null", + "@@ -1,5 +0,0 @@", + "-package internal", + "-", + "-func OldHandler() {", + "- // deprecated", + "-}", + "", + "diff --git a/internal/handler.go b/internal/router.go", + "similarity index 85%", + "rename from internal/handler.go", + "rename to internal/router.go", + "index aaa1111..bbb2222 100644", + "--- a/internal/handler.go", + "+++ b/internal/router.go", + "", + "diff --git a/internal/middleware.go b/internal/middleware.go", + "new file mode 100644", + "index 0000000..def5678", + "--- /dev/null", + "+++ b/internal/middleware.go", + "@@ -0,0 +1,12 @@", + "+package internal", + "+", + '+import "net/http"', + "+", + "+// Logger is a middleware that logs incoming requests.", + "+func Logger(next http.Handler) http.Handler {", + "+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {", + '+ log.Printf("%s %s", r.Method, r.URL.Path)', + "+ next.ServeHTTP(w, r)", + "+ })", + "+}", + ].join("\n"), + }, + { + repo_root: "/home/coder/docs", + branch: "feat/ui-overhaul", + remote_origin: "https://github.com/coder/docs.git", + unified_diff: [ + "diff --git a/guides/setup.md b/guides/setup.md", + "index aaa1111..bbb2222 100644", + "--- a/guides/setup.md", + "+++ b/guides/setup.md", + "@@ -5,7 +5,9 @@ ## Installation", + " ", + " ```bash", + "-npm install @coder/sdk", + "+npm install @coder/sdk@latest", + " ```", + " ", + "+> **Note:** Requires Node.js 18 or later.", + "+", + " ## Configuration", + "", + "diff --git a/guides/auth.md b/guides/auth.md", + "index ccc3333..ddd4444 100644", + "--- a/guides/auth.md", + "+++ b/guides/auth.md", + "@@ -12,8 +12,10 @@ ## Token refresh", + " Tokens are valid for 24 hours.", + " ", + "-To refresh a token, call `refreshToken()`.", + "-This will invalidate the old token.", + "+To refresh a token, call `client.refreshToken()`.", + "+This will invalidate the old token and return a", + "+new one with an extended expiry.", + " ", + " ## Revoking access", + ].join("\n"), + }, + ], + } satisfies TypesGen.WorkspaceAgentGitServerMessage), + }, + ], + }, + }, +}; + +/** + * Sidebar with a single git repo tab (no PR). Because there is only one tab + * the tab bar is hidden and the repo panel is rendered directly. + */ +export const SidebarWithSingleRepo: Story = { + parameters: { + queries: buildQueries( + { + chat: { + id: CHAT_ID, + ...baseChatFields, + title: "Single repo sidebar", + status: "completed", + }, + messages: [], + queued_messages: [], + }, + { diffUrl: undefined }, + ), + webSocket: { + "/git/watch": [ + { + event: "message", + data: JSON.stringify({ + type: "changes", + scanned_at: new Date().toISOString(), + repositories: [ + { + repo_root: "/home/coder/project", + branch: "main", + remote_origin: "https://github.com/coder/project.git", + unified_diff: [ + "diff --git a/src/app.ts b/src/app.ts", + "index aaa1111..bbb2222 100644", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,5 +1,7 @@", + ' import express from "express";', + '+import cors from "cors";', + " ", + " const app = express();", + "+app.use(cors());", + " ", + ' app.get("/", (req, res) => {', + "", + "diff --git a/README.md b/README.md", + "index ccc3333..ddd4444 100644", + "--- a/README.md", + "+++ b/README.md", + "@@ -1,3 +1,5 @@", + " # Project", + " ", + "-A simple app.", + "+A simple app with CORS support.", + "+", + "+## Getting Started", + ].join("\n"), + }, + ], + } satisfies TypesGen.WorkspaceAgentGitServerMessage), + }, + ], + }, + }, +}; /** * Streaming reasoning part via WebSocket — renders collapsed and * can be expanded on click. @@ -522,21 +770,23 @@ export const StreamedReasoningCollapsed: Story = { }, { diffUrl: undefined }, ), - webSocket: [ - { - event: "message", - data: wrapSSE({ - type: "message_part", - message_part: { - part: { - type: "reasoning", - title: "Plan migration", - text: "Streaming reasoning body", + webSocket: { + "/chats/": [ + { + event: "message", + data: wrapSSE({ + type: "message_part", + message_part: { + part: { + type: "reasoning", + title: "Plan migration", + text: "Streaming reasoning body", + }, }, - }, - }), - }, - ], + }), + }, + ], + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/AgentDetail.test.ts b/site/src/pages/AgentsPage/AgentDetail.test.ts index 4c6e54425d..f6549df834 100644 --- a/site/src/pages/AgentsPage/AgentDetail.test.ts +++ b/site/src/pages/AgentsPage/AgentDetail.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react"; - +import { createRef } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatMessageInputRef } from "./AgentChatInput"; import { draftInputStorageKeyPrefix, useConversationEditingState, @@ -17,12 +18,16 @@ describe("useConversationEditingState", () => { const renderEditing = (id: string | undefined = chatID) => { const onSend = vi.fn().mockResolvedValue(undefined); const onDeleteQueuedMessage = vi.fn().mockResolvedValue(undefined); + const chatInputRef = createRef(); + const inputValueRef: import("react").RefObject = { current: "" }; const hook = renderHook(() => useConversationEditingState({ chatID: id, onSend, onDeleteQueuedMessage, + chatInputRef, + inputValueRef, }), ); diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 428c21470a..91bc8ff04a 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -63,9 +63,9 @@ import { import { buildStreamTools } from "./AgentDetail/streamState"; import { AgentDetailTopBar } from "./AgentDetail/TopBar"; import { useMessageWindow } from "./AgentDetail/useMessageWindow"; +import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher"; import type { AgentsOutletContext } from "./AgentsPage"; -import { DiffStatBadge } from "./DiffStats"; -import { FilesChangedPanel } from "./FilesChangedPanel"; + import { getModelCatalogStatusMessage, getModelOptionsFromCatalog, @@ -73,6 +73,8 @@ import { hasConfiguredModelsInCatalog, } from "./modelOptions"; import { RightPanel } from "./RightPanel"; +import { SidebarTabView } from "./SidebarTabView"; +import { useGitWatcher } from "./useGitWatcher"; const noopSetChatErrorReason: AgentsOutletContext["setChatErrorReason"] = () => {}; @@ -295,13 +297,14 @@ export function useConversationEditingState(deps: { chatID: string | undefined; onSend: (message: string, editedMessageID?: number) => Promise; onDeleteQueuedMessage: (id: number) => Promise; + chatInputRef: React.RefObject; + inputValueRef: React.RefObject; }) { - const { chatID, onSend, onDeleteQueuedMessage } = deps; + const { chatID, onSend, onDeleteQueuedMessage, chatInputRef, inputValueRef } = + deps; const draftStorageKey = chatID ? `${draftInputStorageKeyPrefix}${chatID}` : null; - const inputValueRef = useRef(""); - const chatInputRef = useRef(null); const [editorInitialValue, setEditorInitialValue] = useState(() => { if (typeof window === "undefined" || !draftStorageKey) { return ""; @@ -328,7 +331,7 @@ export function useConversationEditingState(deps: { setEditorInitialValue(text); inputValueRef.current = text; }, - [editingMessageId], + [editingMessageId, inputValueRef], ); const handleCancelHistoryEdit = useCallback(() => { @@ -336,7 +339,7 @@ export function useConversationEditingState(deps: { inputValueRef.current = draftBeforeHistoryEdit ?? ""; setEditingMessageId(null); setDraftBeforeHistoryEdit(null); - }, [draftBeforeHistoryEdit]); + }, [draftBeforeHistoryEdit, inputValueRef]); // -- Queue editing state -- const [editingQueuedMessageID, setEditingQueuedMessageID] = useState< @@ -355,7 +358,7 @@ export function useConversationEditingState(deps: { setEditorInitialValue(text); inputValueRef.current = text; }, - [editingQueuedMessageID], + [editingQueuedMessageID, inputValueRef], ); const handleCancelQueueEdit = useCallback(() => { @@ -363,7 +366,7 @@ export function useConversationEditingState(deps: { inputValueRef.current = draftBeforeQueueEdit ?? ""; setEditingQueuedMessageID(null); setDraftBeforeQueueEdit(null); - }, [draftBeforeQueueEdit]); + }, [draftBeforeQueueEdit, inputValueRef]); // Wraps the parent onSend to clear local input/editing state // and handle queue-edit deletion. @@ -393,11 +396,13 @@ export function useConversationEditingState(deps: { }); }, [ + chatInputRef, editingMessageId, editingQueuedMessageID, onDeleteQueuedMessage, onSend, draftStorageKey, + inputValueRef, ], ); @@ -412,7 +417,7 @@ export function useConversationEditingState(deps: { } } }, - [draftStorageKey], + [draftStorageKey, inputValueRef], ); return { @@ -436,7 +441,7 @@ const AgentDetail: FC = () => { const outletContext = useOutletContext(); const queryClient = useQueryClient(); const [selectedModel, setSelectedModel] = useState(""); - const [showDiffPanel, setShowDiffPanel] = useState(false); + const [showSidebarPanel, setShowSidebarPanel] = useState(false); const [isRightPanelExpanded, setIsRightPanelExpanded] = useState(false); // Tracks the live visual expanded state during drag so sibling // content hides/shows in real-time rather than on pointer-up. @@ -464,6 +469,8 @@ const AgentDetail: FC = () => { const onToggleSidebarCollapsed = outletContext?.onToggleSidebarCollapsed ?? (() => {}); const scrollContainerRef = useRef(null); + const chatInputRef = useRef(null); + const inputValueRef = useRef(""); const chatQuery = useQuery({ ...chat(agentId ?? ""), @@ -498,7 +505,7 @@ const AgentDetail: FC = () => { if (hasDiffStatus !== prevHasDiffStatus) { setPrevHasDiffStatus(hasDiffStatus); if (hasDiffStatus) { - setShowDiffPanel(true); + setShowSidebarPanel(true); } } @@ -563,6 +570,44 @@ const AgentDetail: FC = () => { clearChatErrorReason, }); + // Git watcher: runs regardless of sidebar visibility. + const gitWatcher = useGitWatcher({ + chatId: agentId, + }); + + // Detect workspace creation so the sidebar can resolve the + // workspace and display agent/git info. + useWorkspaceCreationWatcher({ + store, + chatID: agentId, + }); + + const handleCommit = useCallback((repoRoot: string) => { + const commitPrompt = `Commit and push the working changes in ${repoRoot}. If there are unstaged files, commit them too.`; + const current = inputValueRef.current; + if (current.includes(commitPrompt)) { + return; + } + const prefix = current.trim() ? "\n\n" : ""; + chatInputRef.current?.insertText(prefix + commitPrompt); + chatInputRef.current?.focus(); + }, []); + + // Auto-open sidebar when git watcher receives its first non-empty + // repositories update. + const [prevHasGitRepos, setPrevHasGitRepos] = useState(false); + const hasGitRepos = gitWatcher.repositories.size > 0; + if (hasGitRepos !== prevHasGitRepos) { + setPrevHasGitRepos(hasGitRepos); + if (hasGitRepos) { + setShowSidebarPanel(true); + } + } + + // Extract PR number from diff status URL. + const prMatch = diffStatusQuery.data?.url?.match(/\/pull\/(\d+)/)?.[1]; + const prNumber = prMatch ? Number(prMatch) : undefined; + useEffect(() => { setSelectedModel((current) => { if (current && modelOptions.some((model) => model.id === current)) { @@ -726,6 +771,8 @@ const AgentDetail: FC = () => { chatID: agentId, onSend: handleSend, onDeleteQueuedMessage: handleDeleteQueuedMessage, + chatInputRef, + inputValueRef, }); const chatTitle = chatQuery.data?.chat?.title; @@ -761,7 +808,7 @@ const AgentDetail: FC = () => { workspace && workspaceAgent && sshConfigQuery.data?.hostname_suffix ? `ssh ${workspaceAgent.name}.${workspace.name}.${workspace.owner_name}.${sshConfigQuery.data.hostname_suffix}` : undefined; - const shouldShowDiffPanel = hasDiffStatus && showDiffPanel; + const shouldShowSidebar = (hasDiffStatus || hasGitRepos) && showSidebarPanel; const generateKeyMutation = useMutation({ mutationFn: () => API.getApiKey(), @@ -834,8 +881,11 @@ const AgentDetail: FC = () => { diff={{ hasDiffStatus: false, diffStatus: undefined, - showDiffPanel: false, - onToggleFilesChanged: () => {}, + hasGitRepos: false, + gitRepoCount: 0, + gitRepositories: new Map(), + showSidebarPanel: false, + onToggleSidebar: () => {}, }} workspace={{ canOpenEditors: false, @@ -909,8 +959,11 @@ const AgentDetail: FC = () => { diff={{ hasDiffStatus: false, diffStatus: undefined, - showDiffPanel: false, - onToggleFilesChanged: () => {}, + hasGitRepos: false, + gitRepoCount: 0, + gitRepositories: new Map(), + showSidebarPanel: false, + onToggleSidebar: () => {}, }} workspace={{ canOpenEditors: false, @@ -939,7 +992,7 @@ const AgentDetail: FC = () => {
{ diff={{ hasDiffStatus, diffStatus: diffStatusQuery.data, - showDiffPanel, - onToggleFilesChanged: () => setShowDiffPanel((prev) => !prev), + hasGitRepos, + gitRepoCount: gitWatcher.repositories.size, + gitRepositories: gitWatcher.repositories, + showSidebarPanel, + onToggleSidebar: () => setShowSidebarPanel((prev) => !prev), }} workspace={{ canOpenEditors, @@ -1039,26 +1095,35 @@ const AgentDetail: FC = () => {
setIsRightPanelExpanded((prev) => !prev)} - onClose={() => setShowDiffPanel(false)} - chatTitle={chatTitle} - isSidebarCollapsed={isSidebarCollapsed} - onToggleSidebarCollapsed={onToggleSidebarCollapsed} + onClose={() => setShowSidebarPanel(false)} onVisualExpandedChange={setDragVisualExpanded} - tabContent={{ - git: ( - - ), - }} - tabMeta={{ - git: , - }} - /> + > + setIsRightPanelExpanded((prev) => !prev)} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + chatTitle={chatTitle} + diffStatus={diffStatusQuery.data} + /> + ); }; diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx index 9a1c14fe88..45f7c33d17 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx @@ -17,8 +17,11 @@ const defaultProps = { diff: { hasDiffStatus: false, diffStatus: undefined, - showDiffPanel: false, - onToggleFilesChanged: () => {}, + hasGitRepos: false, + gitRepoCount: 0, + gitRepositories: new Map(), + showSidebarPanel: false, + onToggleSidebar: () => {}, }, workspace: { canOpenEditors: true, @@ -53,8 +56,11 @@ export const WithDiffStats: Story = { diff: { hasDiffStatus: true, diffStatus: mockDiffStatus, - showDiffPanel: false, - onToggleFilesChanged: () => {}, + hasGitRepos: false, + gitRepoCount: 0, + gitRepositories: new Map(), + showSidebarPanel: false, + onToggleSidebar: () => {}, }, }, }; @@ -64,8 +70,11 @@ export const WithDiffPanelOpen: Story = { diff: { hasDiffStatus: true, diffStatus: mockDiffStatus, - showDiffPanel: true, - onToggleFilesChanged: () => {}, + hasGitRepos: false, + gitRepoCount: 0, + gitRepositories: new Map(), + showSidebarPanel: true, + onToggleSidebar: () => {}, }, }, }; diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx index 9d820323dc..8a4fd234df 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx @@ -28,11 +28,14 @@ import { useNavigate } from "react-router"; import { toast } from "sonner"; import { DiffStatsInline } from "../DiffStats"; -interface DiffPanelState { +interface SidebarPanelState { hasDiffStatus: boolean; diffStatus: ChatDiffStatusResponse | undefined; - showDiffPanel: boolean; - onToggleFilesChanged: () => void; + hasGitRepos: boolean; + gitRepoCount: number; + gitRepositories: ReadonlyMap; + showSidebarPanel: boolean; + onToggleSidebar: () => void; } interface WorkspaceActions { @@ -48,7 +51,7 @@ type AgentDetailTopBarProps = { chatTitle?: string; parentChat?: TypesGen.Chat; onOpenParentChat: (chatId: string) => void; - diff: DiffPanelState; + diff: SidebarPanelState; workspace: WorkspaceActions; onArchiveAgent: () => void; onUnarchiveAgent: () => void; @@ -119,14 +122,16 @@ export const AgentDetailTopBar: FC = ({ {chatTitle} - {diff.hasDiffStatus && diff.diffStatus && !diff.showDiffPanel && ( - - - - )} + {diff.hasDiffStatus && + diff.diffStatus && + !diff.showSidebarPanel && ( + + + + )} {isArchived && ( Archived @@ -230,11 +235,11 @@ export const AgentDetailTopBar: FC = ({ + {expanded && + node.children.map((child) => ( + + ))} + + ); + } + + const isActive = activeFile === node.fullPath; + + return ( + + ); +}; + +// ------------------------------------------------------------------- +// Lazy file diff wrapper +// ------------------------------------------------------------------- + +/** + * Wraps a single `` with an IntersectionObserver so the + * heavy component (Shadow DOM + shiki highlighting) is only mounted + * once the placeholder scrolls into or near the viewport. + * + * Once mounted the component stays mounted — we never unmount a + * FileDiff that the user has already scrolled past, which avoids + * layout shifts and repeated highlighting work. + */ +const LazyFileDiff: FC<{ + fileDiff: FileDiffMetadata; + options: ComponentProps["options"]; +}> = ({ fileDiff, options }) => { + const placeholderRef = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = placeholderRef.current; + if (!el || visible) { + return; + } + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + // Pre-load files that are within one viewport-height of + // the visible area so they are ready before the user + // scrolls to them. + { rootMargin: "100% 0px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [visible]); + + if (!visible) { + return ( +
+ + + + +
+ ); + } + + return ( + + ); +}; + +// ------------------------------------------------------------------- +// Main component +// ------------------------------------------------------------------- + +export const DiffViewer: FC = ({ + headerLeft, + parsedFiles, + isExpanded, + isLoading, + error, + emptyMessage = "No file changes to display.", +}) => { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + const [diffStyle, setDiffStyle] = useState(loadDiffStyle); + const handleSetDiffStyle = useCallback((style: DiffStyle) => { + setDiffStyle(style); + localStorage.setItem(DIFF_STYLE_KEY, style); + }, []); + + const diffOptions = useMemo(() => { + const base = getDiffViewerOptions(isDark); + return { + ...base, + diffStyle, + // Extend the base CSS to make file headers sticky so they + // remain visible while scrolling through long diffs. + unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`, + }; + }, [isDark, diffStyle]); + + // Memoize the per-file options object so every + // receives the same reference and avoids re-highlighting + // when the parent re-renders. + const fileOptions = useMemo( + () => ({ + ...diffOptions, + overflow: "wrap" as const, + enableLineSelection: true, + enableHoverUtility: true, + onLineSelected() { + // TODO: Make this add context to the input so the + // user can type. + }, + }), + [diffOptions], + ); + + const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]); + + // Sort diff blocks in the same order the file tree displays them + // (directories first, then alphabetical) so the rendering is + // consistent regardless of whether the sidebar is visible. + const sortedFiles = useMemo(() => { + const order = new Map(); + let idx = 0; + const walk = (nodes: FileTreeNode[]) => { + for (const node of nodes) { + if (node.type === "file") { + order.set(node.fullPath, idx++); + } else { + walk(node.children); + } + } + }; + walk(fileTree); + return [...parsedFiles].sort( + (a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0), + ); + }, [fileTree, parsedFiles]); + + // --------------------------------------------------------------- + // Container width measurement via ResizeObserver so we can decide + // whether to show the file tree sidebar without a prop from the + // parent. + // --------------------------------------------------------------- + const [containerWidth, setContainerWidth] = useState(0); + const roRef = useRef(null); + const containerRef = useCallback((el: HTMLDivElement | null) => { + if (roRef.current) { + roRef.current.disconnect(); + roRef.current = null; + } + if (!el) { + return; + } + setContainerWidth(el.getBoundingClientRect().width); + const ro = new ResizeObserver(([entry]) => { + setContainerWidth(entry.contentRect.width); + }); + ro.observe(el); + roRef.current = ro; + }, []); + + const showTree = + (isExpanded || containerWidth >= FILE_TREE_THRESHOLD) && + sortedFiles.length > 0; + + // --------------------------------------------------------------- + // Refs for each file diff wrapper so we can scroll-to and track + // which file is currently visible. + // --------------------------------------------------------------- + const fileRefs = useRef>(new Map()); + const [activeFile, setActiveFile] = useState(null); + + // Keep a ref callback that sets up per-file refs. + const setFileRef = useCallback((name: string, el: HTMLDivElement | null) => { + if (el) { + fileRefs.current.set(name, el); + } else { + fileRefs.current.delete(name); + } + }, []); + + // Track which file is at the top of the diff scroll area by + // listening to scroll events on the viewport. The active file + // is whichever file wrapper's top edge is closest to (but not + // below) the container's top — i.e. the one whose sticky + // header would be showing. + const diffViewportRef = useRef(null); + + useEffect(() => { + if (!showTree || sortedFiles.length === 0) { + return; + } + + const viewport = diffViewportRef.current; + if (!viewport) { + return; + } + + let rafId = 0; + const onScroll = () => { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + const containerTop = viewport.getBoundingClientRect().top; + let bestName: string | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const [name, el] of fileRefs.current.entries()) { + const rect = el.getBoundingClientRect(); + // The file "owns" the scroll position when its top + // is at or above the container top and its bottom is + // still below it. + if (rect.bottom > containerTop && rect.top <= containerTop + 1) { + const distance = Math.abs(rect.top - containerTop); + if (distance < bestDistance) { + bestDistance = distance; + bestName = name; + } + } + } + + // If nothing is at the top (e.g. scrolled to the very top + // with padding), pick the first file whose top is closest + // to the container top. + if (!bestName) { + for (const [name, el] of fileRefs.current.entries()) { + const dist = Math.abs( + el.getBoundingClientRect().top - containerTop, + ); + if (dist < bestDistance) { + bestDistance = dist; + bestName = name; + } + } + } + + if (bestName) { + setActiveFile(bestName); + } + }); + }; + + // Fire once to set initial state. + onScroll(); + + viewport.addEventListener("scroll", onScroll, { passive: true }); + return () => { + cancelAnimationFrame(rafId); + viewport.removeEventListener("scroll", onScroll); + }; + }, [showTree, sortedFiles.length]); + + const handleFileClick = useCallback((name: string) => { + const el = fileRefs.current.get(name); + if (el) { + el.scrollIntoView({ block: "start" }); + setActiveFile(name); + } + }, []); + + // --------------------------------------------------------------- + // Loading state + // --------------------------------------------------------------- + if (isLoading) { + return ( +
+
+ {Array.from({ length: 3 }, (_, i) => ( +
+ + + + +
+ ))} +
+
+ ); + } + + // --------------------------------------------------------------- + // Error state + // --------------------------------------------------------------- + if (error) { + return ( +
+ +
+ ); + } + + // --------------------------------------------------------------- + // Main render + // --------------------------------------------------------------- + return ( +
+ {/* Header */} +
+ {headerLeft} + {/* Diff style toggle */} +
+ + +
+
+ {/* Diff contents */} + {sortedFiles.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {/* File tree sidebar */} + {showTree && ( + + + + )} + {/* Diff list */} + { + const vp = node?.querySelector( + "[data-radix-scroll-area-viewport]", + ); + diffViewportRef.current = vp ?? null; + }} + > +
+ {sortedFiles.map((fileDiff) => ( +
setFileRef(fileDiff.name, el)} + > + +
+ ))} + {/* Spacer so the last file can scroll fully to the top. */} +
+
+ +
+ )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/FilesChangedPanel.tsx b/site/src/pages/AgentsPage/FilesChangedPanel.tsx index 82a08c13b1..7b561e514e 100644 --- a/site/src/pages/AgentsPage/FilesChangedPanel.tsx +++ b/site/src/pages/AgentsPage/FilesChangedPanel.tsx @@ -1,84 +1,19 @@ -import { useTheme } from "@emotion/react"; -import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs"; import { parsePatchFiles } from "@pierre/diffs"; -import { FileDiff } from "@pierre/diffs/react"; import { chatDiffContents, chatDiffStatus } from "api/queries/chats"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; import { - DIFFS_FONT_STYLE, - getDiffViewerOptions, -} from "components/ai-elements/tool/utils"; -import { Button } from "components/Button/Button"; -import { FileIcon } from "components/FileIcon/FileIcon"; -import { ScrollArea } from "components/ScrollArea/ScrollArea"; -import { Skeleton } from "components/Skeleton/Skeleton"; -import { - ChevronRightIcon, - Columns2Icon, ExternalLinkIcon, GitBranchIcon, GitPullRequestIcon, - Rows3Icon, } from "lucide-react"; -import { - type ComponentProps, - type FC, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { type FC, useMemo } from "react"; import { useQuery } from "react-query"; -import { cn } from "utils/cn"; +import { DiffViewer } from "./DiffViewer"; interface FilesChangedPanelProps { chatId: string; isExpanded?: boolean; } -/** - * Minimum container width (px) at which the file tree sidebar - * is shown alongside the diff list. - */ -const FILE_TREE_THRESHOLD = 1000; - -/** - * Extra CSS injected via the diff viewer's `unsafeCSS` option to make - * file headers sticky and adjust metadata layout. - */ -const STICKY_HEADER_CSS = [ - "[data-diffs-header] {", - " position: sticky; top: 0; z-index: 10;", - " font-size: 13px;", - " border-bottom: 1px solid hsl(var(--border-default));", - " background-color: hsl(var(--surface-quaternary)) !important;", - "}", - "[data-diffs-header] [data-metadata] { flex-direction: row-reverse; }", - "@media (prefers-color-scheme: dark) {", - " [data-diffs-header] { background-color: hsl(var(--surface-secondary)) !important; }", - "}", -].join(" "); - -type DiffStyle = "unified" | "split"; -const DIFF_STYLE_KEY = "agents.diff-view-style"; - -function loadDiffStyle(): DiffStyle { - if (typeof window === "undefined") { - return "unified"; - } - const stored = localStorage.getItem(DIFF_STYLE_KEY); - if (stored === "split" || stored === "unified") { - return stored; - } - return "unified"; -} - -/** - * Width of the file tree sidebar in pixels. - */ -const FILE_TREE_WIDTH = 300; - /** * Parses a GitHub PR URL into its components. * Returns null if parsing fails. @@ -99,254 +34,10 @@ function parsePullRequestUrl(url: string): { return null; } -// ------------------------------------------------------------------- -// File tree data model -// ------------------------------------------------------------------- - -/** Maps a diff change type to a Tailwind text-color class. */ -function changeColor(type?: ChangeTypes): string | undefined { - switch (type) { - case "new": - return "text-green-700 dark:text-green-300"; - case "deleted": - return "text-red-700 dark:text-red-300"; - case "rename-pure": - case "rename-changed": - return "text-orange-700 dark:text-orange-300"; - case "change": - return "text-orange-700 dark:text-orange-300"; - default: - return undefined; - } -} - -/** Short letter shown after the filename, matching VS Code style. */ -function changeLabel(type: ChangeTypes): string { - switch (type) { - case "new": - return "A"; - case "deleted": - return "D"; - case "rename-pure": - case "rename-changed": - return "R"; - case "change": - return "M"; - default: - return ""; - } -} - -interface FileTreeNode { - name: string; - fullPath: string; - type: "file" | "directory"; - children: FileTreeNode[]; - fileDiff?: FileDiffMetadata; -} - -/** - * Builds a nested tree from a flat list of file diffs. Directory - * nodes are created for every intermediate path segment. The - * result is sorted with directories first, then alphabetically. - * Single-child directory chains are collapsed so that e.g. - * `src/pages/AgentsPage` renders as one row. - */ -function buildFileTree(files: FileDiffMetadata[]): FileTreeNode[] { - const root: FileTreeNode[] = []; - - for (const file of files) { - const segments = file.name.split("/"); - let children = root; - - // Walk / create intermediate directory nodes. - for (let i = 0; i < segments.length - 1; i++) { - const seg = segments[i]; - let dir = children.find((n) => n.type === "directory" && n.name === seg); - if (!dir) { - dir = { - name: seg, - fullPath: segments.slice(0, i + 1).join("/"), - type: "directory", - children: [], - }; - children.push(dir); - } - children = dir.children; - } - - // Leaf file node. - const fileName = segments[segments.length - 1]; - children.push({ - name: fileName, - fullPath: file.name, - type: "file", - children: [], - fileDiff: file, - }); - } - - const sortNodes = (nodes: FileTreeNode[]): FileTreeNode[] => { - for (const node of nodes) { - if (node.children.length > 0) { - node.children = sortNodes(node.children); - } - } - return nodes.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - }; - - // Collapse single-child directory chains into one node whose - // name uses path separators, e.g. "src/pages/AgentsPage". - const collapse = (nodes: FileTreeNode[]): FileTreeNode[] => { - for (const node of nodes) { - if (node.type === "directory") { - node.children = collapse(node.children); - // If this directory has exactly one child and it is also - // a directory, merge them. - while ( - node.children.length === 1 && - node.children[0].type === "directory" - ) { - const child = node.children[0]; - node.name = `${node.name}/${child.name}`; - node.fullPath = child.fullPath; - node.children = child.children; - } - } - } - return nodes; - }; - - return collapse(sortNodes(root)); -} - -// ------------------------------------------------------------------- -// Tree node renderer -// ------------------------------------------------------------------- - -const FileTreeNodeView: FC<{ - node: FileTreeNode; - depth: number; - activeFile: string | null; - onFileClick: (fullPath: string) => void; -}> = ({ node, depth, activeFile, onFileClick }) => { - const [expanded, setExpanded] = useState(true); - - if (node.type === "directory") { - return ( -
- - {expanded && - node.children.map((child) => ( - - ))} -
- ); - } - - const isActive = activeFile === node.fullPath; - - return ( - - ); -}; - export const FilesChangedPanel: FC = ({ chatId, isExpanded, }) => { - const theme = useTheme(); - const isDark = theme.palette.mode === "dark"; - const [diffStyle, setDiffStyle] = useState(loadDiffStyle); - const handleSetDiffStyle = useCallback((style: DiffStyle) => { - setDiffStyle(style); - localStorage.setItem(DIFF_STYLE_KEY, style); - }, []); - - const diffOptions = useMemo(() => { - const base = getDiffViewerOptions(isDark); - return { - ...base, - diffStyle, - // Extend the base CSS to make file headers sticky so they - // remain visible while scrolling through long diffs. - unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`, - }; - }, [isDark, diffStyle]); - - // Memoize the per-file options object so every - // receives the same reference and avoids re-highlighting - // when the parent re-renders. - const fileOptions = useMemo( - () => ({ - ...diffOptions, - overflow: "wrap" as const, - enableLineSelection: true, - enableHoverUtility: true, - onLineSelected() { - // TODO: Make this add context to the input so the - // user can type. - }, - }), - [diffOptions], - ); - const diffStatusQuery = useQuery(chatDiffStatus(chatId)); const diffContentsQuery = useQuery({ ...chatDiffContents(chatId), @@ -369,380 +60,52 @@ export const FilesChangedPanel: FC = ({ } }, [diffContentsQuery.data?.diff, chatId]); - const fileTree = useMemo(() => buildFileTree(parsedFiles), [parsedFiles]); - - // Sort diff blocks in the same order the file tree displays them - // (directories first, then alphabetical) so the rendering is - // consistent regardless of whether the sidebar is visible. - const sortedFiles = useMemo(() => { - const order = new Map(); - let idx = 0; - const walk = (nodes: FileTreeNode[]) => { - for (const node of nodes) { - if (node.type === "file") { - order.set(node.fullPath, idx++); - } else { - walk(node.children); - } - } - }; - walk(fileTree); - return [...parsedFiles].sort( - (a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0), - ); - }, [fileTree, parsedFiles]); - const pullRequestUrl = diffStatusQuery.data?.url; const parsedPr = pullRequestUrl ? parsePullRequestUrl(pullRequestUrl) : null; - // --------------------------------------------------------------- - // Container width measurement via ResizeObserver so we can decide - // whether to show the file tree sidebar without a prop from the - // parent. - // --------------------------------------------------------------- - const [containerWidth, setContainerWidth] = useState(0); - const roRef = useRef(null); - const containerRef = useCallback((el: HTMLDivElement | null) => { - if (roRef.current) { - roRef.current.disconnect(); - roRef.current = null; - } - if (!el) { - return; - } - setContainerWidth(el.getBoundingClientRect().width); - const ro = new ResizeObserver(([entry]) => { - setContainerWidth(entry.contentRect.width); - }); - ro.observe(el); - roRef.current = ro; - }, []); - - const showTree = - (isExpanded || containerWidth >= FILE_TREE_THRESHOLD) && - sortedFiles.length > 0; - - // --------------------------------------------------------------- - // Refs for each file diff wrapper so we can scroll-to and track - // which file is currently visible. - // --------------------------------------------------------------- - const fileRefs = useRef>(new Map()); - const [activeFile, setActiveFile] = useState(null); - - // Keep a ref callback that sets up per-file refs. - const setFileRef = useCallback((name: string, el: HTMLDivElement | null) => { - if (el) { - fileRefs.current.set(name, el); - } else { - fileRefs.current.delete(name); - } - }, []); - - // Track which file is at the top of the diff scroll area by - // listening to scroll events on the viewport. The active file - // is whichever file wrapper's top edge is closest to (but not - // below) the container's top — i.e. the one whose sticky - // header would be showing. - const diffViewportRef = useRef(null); - - useEffect(() => { - if (!showTree || sortedFiles.length === 0) { - return; - } - - const viewport = diffViewportRef.current; - if (!viewport) { - return; - } - - let rafId = 0; - const onScroll = () => { - cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(() => { - const containerTop = viewport.getBoundingClientRect().top; - let bestName: string | null = null; - let bestDistance = Number.POSITIVE_INFINITY; - - for (const [name, el] of fileRefs.current.entries()) { - const rect = el.getBoundingClientRect(); - // The file "owns" the scroll position when its top - // is at or above the container top and its bottom is - // still below it. - if (rect.bottom > containerTop && rect.top <= containerTop + 1) { - const distance = Math.abs(rect.top - containerTop); - if (distance < bestDistance) { - bestDistance = distance; - bestName = name; - } - } - } - - // If nothing is at the top (e.g. scrolled to the very top - // with padding), pick the first file whose top is closest - // to the container top. - if (!bestName) { - for (const [name, el] of fileRefs.current.entries()) { - const dist = Math.abs( - el.getBoundingClientRect().top - containerTop, - ); - if (dist < bestDistance) { - bestDistance = dist; - bestName = name; - } - } - } - - if (bestName) { - setActiveFile(bestName); - } - }); - }; - - // Fire once to set initial state. - onScroll(); - - viewport.addEventListener("scroll", onScroll, { passive: true }); - return () => { - cancelAnimationFrame(rafId); - viewport.removeEventListener("scroll", onScroll); - }; - }, [showTree, sortedFiles.length]); - - const handleFileClick = useCallback((name: string) => { - const el = fileRefs.current.get(name); - if (el) { - el.scrollIntoView({ block: "start" }); - setActiveFile(name); - } - }, []); - - if (diffContentsQuery.isLoading || diffStatusQuery.isLoading) { - return ( -
-
- {Array.from({ length: 3 }, (_, i) => ( -
- - - - -
- ))} -
-
- ); - } - - if (diffContentsQuery.isError) { - return ( -
- -
- ); - } - - return ( -
- {/* Header */} -
- {pullRequestUrl && parsedPr ? ( - - - - - {parsedPr.owner}/{parsedPr.repo} - - #{parsedPr.number} - - - - ) : pullRequestUrl ? ( - - - {pullRequestUrl} - - - ) : ( -
- - Uncommitted changes -
- )} - {/* Diff style toggle */} -
- - -
-
- {/* Diff contents */} - {sortedFiles.length === 0 ? ( -
- No file changes to display. -
- ) : ( -
- {/* File tree sidebar */} - {showTree && ( - - - - )} - {/* Diff list */} - { - const vp = node?.querySelector( - "[data-radix-scroll-area-viewport]", - ); - diffViewportRef.current = vp ?? null; - }} - > -
- {sortedFiles.map((fileDiff) => ( -
setFileRef(fileDiff.name, el)} - > - -
- ))} - {/* Spacer so the last file can scroll fully to the top. */} -
-
- -
- )} + const headerLeft = pullRequestUrl ? ( + parsedPr ? ( + + + + + {parsedPr.owner}/{parsedPr.repo} + + #{parsedPr.number} + + + + ) : ( + + + {pullRequestUrl} + + + ) + ) : ( +
+ + Uncommitted changes
); -}; - -// ----------------------------------------------------------------------- -// Estimated height per line in the diff viewer (px). Derived from -// the --diffs-font-size (11px) and --diffs-line-height (1.5) -// values set via DIFFS_FONT_STYLE, plus 1px for the border/gap. -// ----------------------------------------------------------------------- -const LINE_HEIGHT_PX = 17.5; - -// Height of the file header row rendered by @pierre/diffs. -const HEADER_HEIGHT_PX = 36; - -/** - * Estimate the rendered pixel height of a file diff so the - * placeholder occupies roughly the same space. This keeps the - * scroll position stable as files are lazily mounted. - */ -function estimateDiffHeight(fileDiff: FileDiffMetadata): number { - return HEADER_HEIGHT_PX + fileDiff.unifiedLineCount * LINE_HEIGHT_PX; -} - -/** - * Wraps a single `` with an IntersectionObserver so the - * heavy component (Shadow DOM + shiki highlighting) is only mounted - * once the placeholder scrolls into or near the viewport. - * - * Once mounted the component stays mounted — we never unmount a - * FileDiff that the user has already scrolled past, which avoids - * layout shifts and repeated highlighting work. - */ -const LazyFileDiff: FC<{ - fileDiff: FileDiffMetadata; - options: ComponentProps["options"]; -}> = ({ fileDiff, options }) => { - const placeholderRef = useRef(null); - const [visible, setVisible] = useState(false); - - useEffect(() => { - const el = placeholderRef.current; - if (!el || visible) { - return; - } - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setVisible(true); - observer.disconnect(); - } - }, - // Pre-load files that are within one viewport-height of - // the visible area so they are ready before the user - // scrolls to them. - { rootMargin: "100% 0px" }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [visible]); - - if (!visible) { - return ( -
- - - - -
- ); - } return ( - + ); }; diff --git a/site/src/pages/AgentsPage/RepoChangesPanel.stories.tsx b/site/src/pages/AgentsPage/RepoChangesPanel.stories.tsx new file mode 100644 index 0000000000..e51071be09 --- /dev/null +++ b/site/src/pages/AgentsPage/RepoChangesPanel.stories.tsx @@ -0,0 +1,87 @@ +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 ++++ b/src/main.ts +@@ -1,5 +1,7 @@ + import { start } from "./server"; ++import { logger } from "./logger"; + + const port = 3000; ++logger.info("Starting server..."); + start(port); +--- a/src/server.ts ++++ b/src/server.ts +@@ -10,3 +10,5 @@ + app.listen(port, () => { + console.log("Listening on port " + port); + }); ++ ++ return app; + } +`; + +const baseRepo: WorkspaceAgentRepoChanges = { + repo_root: "/home/coder/project", + branch: "feat/add-logging", + remote_origin: "https://github.com/coder/project.git", + unified_diff: sampleDiff, +}; + +const meta: Meta = { + title: "pages/AgentsPage/RepoChangesPanel", + component: RepoChangesPanel, + args: { + repo: baseRepo, + onRefresh: fn(), + onCommit: fn(), + }, +}; +export default meta; +type Story = StoryObj; + +export const WithChanges: Story = {}; + +export const NoChanges: Story = { + args: { + repo: { + ...baseRepo, + unified_diff: undefined, + }, + }, +}; + +export const LongRepoName: Story = { + args: { + repo: { + ...baseRepo, + repo_root: + "/home/coder/very-long-repository-name-that-should-be-truncated-in-the-header", + }, + }, +}; + +export const LongBranchName: Story = { + args: { + repo: { + ...baseRepo, + branch: + "feature/TICKET-12345-implement-very-long-branch-name-for-testing-truncation-behavior", + }, + }, +}; + +export const EmptyBranchName: Story = { + args: { + repo: { + ...baseRepo, + branch: "", + }, + }, +}; + +export const ManyFiles: Story = {}; + +export const UntrackedFiles: Story = {}; diff --git a/site/src/pages/AgentsPage/RepoChangesPanel.tsx b/site/src/pages/AgentsPage/RepoChangesPanel.tsx new file mode 100644 index 0000000000..f62a2024b2 --- /dev/null +++ b/site/src/pages/AgentsPage/RepoChangesPanel.tsx @@ -0,0 +1,120 @@ +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 { DiffViewer } from "./DiffViewer"; + +interface RepoChangesPanelProps { + repo: WorkspaceAgentRepoChanges; + onRefresh: () => void; + onCommit: () => void; + isExpanded?: boolean; +} + +function splitRepoPath(repoRoot: string): { parent: string; name: string } { + const lastSlash = repoRoot.lastIndexOf("/"); + if (lastSlash === -1) { + return { parent: "", name: repoRoot }; + } + return { + parent: repoRoot.slice(0, lastSlash + 1), + name: repoRoot.slice(lastSlash + 1), + }; +} + +export const RepoChangesPanel: FC = ({ + repo, + onRefresh, + onCommit, + isExpanded, +}) => { + const [spinning, setSpinning] = useState(false); + const spinTimerRef = useRef>(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) { + return []; + } + try { + const patches = parsePatchFiles(diff); + return patches.flatMap((p) => p.files); + } catch { + return []; + } + }, [repo.unified_diff]); + + const { parent: repoParent, name: repoName } = splitRepoPath(repo.repo_root); + const hasChanges = parsedFiles.length > 0; + + return ( + + + + {repoName} + + + {repoParent} + + {repo.branch?.trim() && ( +
+ + {repo.branch} +
+ )} +
+ + +
+
+ } + parsedFiles={parsedFiles} + isExpanded={isExpanded} + emptyMessage="No file changes." + /> + ); +}; diff --git a/site/src/pages/AgentsPage/RightPanel.tsx b/site/src/pages/AgentsPage/RightPanel.tsx index adfea92b8e..e23b2cae26 100644 --- a/site/src/pages/AgentsPage/RightPanel.tsx +++ b/site/src/pages/AgentsPage/RightPanel.tsx @@ -1,5 +1,3 @@ -import { Button } from "components/Button/Button"; -import { MaximizeIcon, MinimizeIcon, PanelLeftIcon } from "lucide-react"; import { type ReactNode, type PointerEvent as ReactPointerEvent, @@ -15,8 +13,6 @@ const MIN_WIDTH = 360; const MAX_WIDTH_RATIO = 0.7; const DEFAULT_WIDTH = 480; -const TABS = [{ id: "git", label: "Git" }]; - const SNAP_THRESHOLD = 80; function getMaxWidth(): number { @@ -46,27 +42,20 @@ interface RightPanelProps { isExpanded: boolean; onToggleExpanded: () => void; onClose: () => void; - chatTitle?: string; - isSidebarCollapsed?: boolean; - onToggleSidebarCollapsed?: () => void; - tabContent: Partial>; - /** Optional extra info per tab (e.g. diff stats). */ - tabMeta?: Partial>; /** Fires during drag with the live visual expanded state, and * null when the drag ends so the parent falls back to the * committed isExpanded prop. */ onVisualExpandedChange?: (visualExpanded: boolean | null) => void; + children: ReactNode; } /** * Encapsulates all drag/resize logic for the right panel: - * refs, pointer handlers, snap state, sidebar collapse - * tracking, and visual state derivation. + * refs, pointer handlers, snap state, and visual state + * derivation. */ function useResizableDrag({ isExpanded, - isSidebarCollapsed, - onToggleSidebarCollapsed, width, setWidth, isOpen, @@ -74,8 +63,6 @@ function useResizableDrag({ onVisualExpandedChange, }: { isExpanded: boolean; - isSidebarCollapsed?: boolean; - onToggleSidebarCollapsed?: () => void; width: number; setWidth: React.Dispatch>; isOpen: boolean; @@ -90,15 +77,11 @@ function useResizableDrag({ const [dragSnap, setDragSnap] = useState< "normal" | "expanded" | "closed" | null >(null); - // Whether we collapsed the sidebar during this drag gesture. - // Used to reverse it if the user drags back. - const sidebarCollapsedByDrag = useRef(false); const handlePointerDown = useCallback( (e: ReactPointerEvent) => { e.preventDefault(); isDragging.current = true; - sidebarCollapsedByDrag.current = false; setDragSnap(null); startX.current = e.clientX; startWidth.current = isExpanded @@ -120,23 +103,6 @@ function useResizableDrag({ const raw = startWidth.current + delta; const maxWidth = getMaxWidth(); - // Collapse/uncollapse the sidebar live when the pointer - // reaches the left edge of the viewport. - if (e.clientX < SNAP_THRESHOLD && !sidebarCollapsedByDrag.current) { - if (!isSidebarCollapsed && onToggleSidebarCollapsed) { - onToggleSidebarCollapsed(); - sidebarCollapsedByDrag.current = true; - } - } else if ( - e.clientX >= SNAP_THRESHOLD && - sidebarCollapsedByDrag.current - ) { - if (onToggleSidebarCollapsed) { - onToggleSidebarCollapsed(); - sidebarCollapsedByDrag.current = false; - } - } - let nextSnap: "normal" | "expanded" | "closed"; if (raw > maxWidth + SNAP_THRESHOLD) { nextSnap = "expanded"; @@ -155,13 +121,7 @@ function useResizableDrag({ (nextSnap !== "normal" && nextSnap !== "closed" && isExpanded); onVisualExpandedChange?.(nextVisualExpanded); }, - [ - isSidebarCollapsed, - onToggleSidebarCollapsed, - setWidth, - isExpanded, - onVisualExpandedChange, - ], + [setWidth, isExpanded, onVisualExpandedChange], ); const handlePointerUp = useCallback( @@ -200,7 +160,6 @@ function useResizableDrag({ handlePointerDown, handlePointerMove, handlePointerUp, - sidebarCollapsedByDrag, }; } @@ -209,14 +168,9 @@ export const RightPanel = ({ isExpanded, onToggleExpanded, onClose, - chatTitle, - isSidebarCollapsed, - onToggleSidebarCollapsed, - tabContent, - tabMeta, onVisualExpandedChange, + children, }: RightPanelProps) => { - const [activeTab, setActiveTab] = useState("git"); const [width, setWidth] = useState(loadPersistedWidth); // Clamp width when the viewport shrinks so the panel @@ -254,8 +208,6 @@ export const RightPanel = ({ handlePointerUp, } = useResizableDrag({ isExpanded, - isSidebarCollapsed, - onToggleSidebarCollapsed, width, setWidth, isOpen, @@ -298,64 +250,7 @@ export const RightPanel = ({ visualExpanded && "-left-1", )} /> -
- {/* Tabbed header */} -
- {/* Left side: sidebar toggle (expanded + collapsed only) + tabs */} -
- {visualExpanded && - isSidebarCollapsed && - onToggleSidebarCollapsed && ( - - )} - {TABS.map((tab) => ( - - ))} -
- {/* Center: chat title */}{" "} -
- {visualExpanded && chatTitle && ( - - {chatTitle} - - )} -
- {/* Right side: expand/contract button */} - -
-
- {tabContent[activeTab]} -
-
+
{children}
); }; diff --git a/site/src/pages/AgentsPage/SidebarTabView.stories.tsx b/site/src/pages/AgentsPage/SidebarTabView.stories.tsx new file mode 100644 index 0000000000..eaeb5159b9 --- /dev/null +++ b/site/src/pages/AgentsPage/SidebarTabView.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { WorkspaceAgentRepoChanges } from "api/typesGenerated"; +import { fn } from "storybook/test"; +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 makeRepo = ( + name: string, + overrides?: Partial, +): WorkspaceAgentRepoChanges => ({ + repo_root: `/home/coder/${name}`, + branch: "main", + remote_origin: `https://github.com/coder/${name}.git`, + unified_diff: sampleDiff, + ...overrides, +}); + +const meta: Meta = { + title: "pages/AgentsPage/SidebarTabView", + component: SidebarTabView, + args: { + workspace: { name: "my-workspace", ownerName: "admin" }, + onRefresh: fn(), + onCommit: fn(), + isExpanded: false, + onToggleExpanded: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const PROnly: Story = { + args: { + prTab: { prNumber: 42, chatId: "chat-1" }, + repositories: new Map(), + }, +}; + +export const SingleRepo: 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), + ]), + ), + }, +}; + +export const EmptyState: Story = { + args: { + prTab: undefined, + repositories: new Map(), + }, +}; diff --git a/site/src/pages/AgentsPage/SidebarTabView.tsx b/site/src/pages/AgentsPage/SidebarTabView.tsx new file mode 100644 index 0000000000..23f43a954d --- /dev/null +++ b/site/src/pages/AgentsPage/SidebarTabView.tsx @@ -0,0 +1,291 @@ +import { parsePatchFiles } from "@pierre/diffs"; +import type { WorkspaceAgentRepoChanges } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { + FolderIcon, + GitPullRequestIcon, + MaximizeIcon, + MinimizeIcon, + PanelLeftIcon, +} from "lucide-react"; +import { type FC, useMemo, useState } from "react"; +import { cn } from "utils/cn"; +import { FilesChangedPanel } from "./FilesChangedPanel"; +import { RepoChangesPanel } from "./RepoChangesPanel"; + +interface SidebarTabViewProps { + /** PR tab data. Omitted if no PR is associated. */ + prTab?: { + prNumber: number; + chatId: string; + }; + /** Repository tabs from git watcher. */ + repositories: ReadonlyMap; + /** 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; + /** Whether the panel is in expanded/fullscreen mode. */ + isExpanded: boolean; + /** Callback to toggle expanded state. */ + onToggleExpanded: () => void; + /** Whether the left sidebar is collapsed. */ + isSidebarCollapsed?: boolean; + /** Callback to toggle left sidebar. */ + onToggleSidebarCollapsed?: () => void; + /** Shown in center when expanded. */ + chatTitle?: string; + /** PR diff stats for the PR tab. */ + diffStatus?: { additions?: number; deletions?: number }; +} + +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 = ({ + prTab, + repositories, + onRefresh, + onCommit, + isExpanded, + onToggleExpanded, + isSidebarCollapsed, + onToggleSidebarCollapsed, + chatTitle, + diffStatus, +}) => { + const repoEntries = Array.from(repositories.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + + const hasPR = Boolean(prTab); + const hasRepos = repoEntries.length > 0; + + // Default active tab: PR if present, otherwise first repo. + const defaultTab = hasPR + ? "pr" + : repoEntries.length > 0 + ? repoEntries[0][0] + : null; + + const [activeTab, setActiveTab] = useState(defaultTab); + + // 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; + + if (!hasPR && !hasRepos) { + return ( +
+ {/* Tab bar – always visible for the expand button. */} +
+
+ {isExpanded && chatTitle && ( + + {chatTitle} + + )} +
+ +
+
+ No changes to display. +
+
+ ); + } + + return ( +
+ {/* Tab bar */} +
+ {/* Sidebar toggle – only when expanded and sidebar is collapsed */} + {isExpanded && isSidebarCollapsed && onToggleSidebarCollapsed && ( + + )} + + {/* Tabs */} + {hasPR && prTab && ( + + )} + {repoEntries.map(([repoRoot]) => { + const stats = repoDiffStats.get(repoRoot); + const additions = stats?.additions ?? 0; + const deletions = stats?.deletions ?? 0; + return ( + + ); + })} + + {/* Center: chat title when expanded */} +
+ {isExpanded && chatTitle && ( + + {chatTitle} + + )} +
+ + {/* Right side: expand/contract button */} + +
+ + {/* Tab content */} +
+ {effectiveTab === "pr" && prTab ? ( + + ) : effectiveTab && repositories.has(effectiveTab) ? ( + onCommit(effectiveTab)} + isExpanded={isExpanded} + /> + ) : null} +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/useGitWatcher.test.ts b/site/src/pages/AgentsPage/useGitWatcher.test.ts new file mode 100644 index 0000000000..f71e43aa94 --- /dev/null +++ b/site/src/pages/AgentsPage/useGitWatcher.test.ts @@ -0,0 +1,327 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useGitWatcher } from "./useGitWatcher"; + +vi.mock("api/api", () => ({ + watchChatGit: vi.fn(), +})); + +import { watchChatGit } from "api/api"; + +const mockWatchChatGit = vi.mocked(watchChatGit); + +class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + + readyState = MockWebSocket.OPEN; + private listeners = new Map void>>(); + + addEventListener(event: string, handler: (...args: unknown[]) => void) { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(handler); + } + + removeEventListener(event: string, handler: (...args: unknown[]) => void) { + this.listeners.get(event)?.delete(handler); + } + + send = vi.fn(); + close = vi.fn(() => { + this.readyState = MockWebSocket.CLOSED; + }); + + simulateOpen() { + this.readyState = MockWebSocket.OPEN; + for (const handler of this.listeners.get("open") ?? []) { + handler(); + } + } + + simulateMessage(data: unknown) { + for (const handler of this.listeners.get("message") ?? []) { + handler({ data: JSON.stringify(data) }); + } + } + + simulateClose() { + this.readyState = MockWebSocket.CLOSED; + for (const handler of this.listeners.get("close") ?? []) { + handler(); + } + } +} + +function createMockSocket(): MockWebSocket { + const socket = new MockWebSocket(); + mockWatchChatGit.mockReturnValue(socket as unknown as WebSocket); + return socket; +} + +describe("useGitWatcher", () => { + beforeEach(() => { + mockWatchChatGit.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("connects WebSocket when chatId is provided", () => { + const socket = createMockSocket(); + + const { result } = renderHook(() => useGitWatcher({ chatId: "chat-123" })); + + expect(mockWatchChatGit).toHaveBeenCalledWith("chat-123"); + expect(result.current.isConnected).toBe(false); + + act(() => socket.simulateOpen()); + expect(result.current.isConnected).toBe(true); + }); + + it("does not connect when chatId is undefined", () => { + const { result } = renderHook(() => useGitWatcher({ chatId: undefined })); + + expect(mockWatchChatGit).not.toHaveBeenCalled(); + expect(result.current.isConnected).toBe(false); + expect(result.current.repositories.size).toBe(0); + }); + + it("populates repositories map from incoming changes messages", async () => { + const socket = createMockSocket(); + + const { result } = renderHook(() => useGitWatcher({ chatId: "chat-123" })); + + act(() => socket.simulateOpen()); + + act(() => { + socket.simulateMessage({ + type: "changes", + repositories: [ + { + repo_root: "/home/user/project-a", + branch: "main", + unified_diff: "diff content a", + }, + { + repo_root: "/home/user/project-b", + branch: "feature", + unified_diff: "diff content b", + }, + ], + }); + }); + + await waitFor(() => { + expect(result.current.repositories.size).toBe(2); + }); + + const repoA = result.current.repositories.get("/home/user/project-a"); + expect(repoA).toEqual({ + repo_root: "/home/user/project-a", + branch: "main", + unified_diff: "diff content a", + }); + + const repoB = result.current.repositories.get("/home/user/project-b"); + expect(repoB).toEqual({ + repo_root: "/home/user/project-b", + branch: "feature", + unified_diff: "diff content b", + }); + }); + + it("evicts repos with removed: true", async () => { + const socket = createMockSocket(); + + const { result } = renderHook(() => useGitWatcher({ chatId: "chat-123" })); + + act(() => socket.simulateOpen()); + + // First, populate with two repos. + act(() => { + socket.simulateMessage({ + type: "changes", + repositories: [ + { + repo_root: "/home/user/project-a", + branch: "main", + }, + { + repo_root: "/home/user/project-b", + branch: "feature", + }, + ], + }); + }); + + await waitFor(() => { + expect(result.current.repositories.size).toBe(2); + }); + + // Remove one of them. + act(() => { + socket.simulateMessage({ + type: "changes", + repositories: [ + { + repo_root: "/home/user/project-a", + branch: "", + removed: true, + }, + ], + }); + }); + + await waitFor(() => { + expect(result.current.repositories.size).toBe(1); + }); + + expect(result.current.repositories.has("/home/user/project-a")).toBe(false); + expect(result.current.repositories.has("/home/user/project-b")).toBe(true); + }); + + it("reconnects with exponential backoff on close", () => { + vi.useFakeTimers(); + + try { + const socket1 = createMockSocket(); + + renderHook(() => useGitWatcher({ chatId: "chat-123" })); + + expect(mockWatchChatGit).toHaveBeenCalledTimes(1); + act(() => socket1.simulateOpen()); + + // Close the socket to trigger reconnection (attempt 0 → 1000ms). + const socket2 = createMockSocket(); + act(() => socket1.simulateClose()); + + // Before the timer fires, no reconnection yet. + expect(mockWatchChatGit).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(1000)); + expect(mockWatchChatGit).toHaveBeenCalledTimes(2); + + // Close again (attempt 1 → 2000ms). + const socket3 = createMockSocket(); + act(() => socket2.simulateClose()); + + act(() => vi.advanceTimersByTime(1999)); + expect(mockWatchChatGit).toHaveBeenCalledTimes(2); + act(() => vi.advanceTimersByTime(1)); + expect(mockWatchChatGit).toHaveBeenCalledTimes(3); + + // Close again (attempt 2 → 4000ms). + createMockSocket(); + act(() => socket3.simulateClose()); + + act(() => vi.advanceTimersByTime(3999)); + expect(mockWatchChatGit).toHaveBeenCalledTimes(3); + act(() => vi.advanceTimersByTime(1)); + expect(mockWatchChatGit).toHaveBeenCalledTimes(4); + } finally { + vi.useRealTimers(); + } + }); + + it("sends a refresh message over the socket", () => { + const socket = createMockSocket(); + + const { result } = renderHook(() => useGitWatcher({ chatId: "chat-123" })); + + act(() => socket.simulateOpen()); + + act(() => result.current.refresh()); + + expect(socket.send).toHaveBeenCalledTimes(1); + expect(socket.send).toHaveBeenCalledWith( + JSON.stringify({ type: "refresh" }), + ); + }); + + it("cleans up WebSocket and timers on unmount", () => { + vi.useFakeTimers(); + + try { + const socket = createMockSocket(); + + const { unmount } = renderHook(() => + useGitWatcher({ chatId: "chat-123" }), + ); + + act(() => socket.simulateOpen()); + expect(socket.close).not.toHaveBeenCalled(); + + unmount(); + + expect(socket.close).toHaveBeenCalledTimes(1); + + // Verify that no reconnection happens after unmount by closing + // the socket and advancing timers. + mockWatchChatGit.mockClear(); + act(() => vi.advanceTimersByTime(60_000)); + expect(mockWatchChatGit).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("resets repositories when chatId changes", async () => { + const socket1 = createMockSocket(); + + const { result, rerender } = renderHook( + ({ chatId }: { chatId: string | undefined }) => useGitWatcher({ chatId }), + { initialProps: { chatId: "chat-aaa" as string | undefined } }, + ); + + act(() => socket1.simulateOpen()); + + act(() => { + socket1.simulateMessage({ + type: "changes", + repositories: [ + { + repo_root: "/home/user/project-a", + branch: "main", + }, + ], + }); + }); + + await waitFor(() => { + expect(result.current.repositories.size).toBe(1); + }); + + // The old socket should be closed when we switch chatId. + const socket2 = createMockSocket(); + rerender({ chatId: "chat-bbb" }); + + expect(socket1.close).toHaveBeenCalled(); + expect(mockWatchChatGit).toHaveBeenCalledWith("chat-bbb"); + + // Repositories should be reset immediately after chatId changes. + expect(result.current.repositories.size).toBe(0); + + // The new socket should work independently. + act(() => socket2.simulateOpen()); + + act(() => { + socket2.simulateMessage({ + type: "changes", + repositories: [ + { + repo_root: "/home/user/project-x", + branch: "develop", + }, + ], + }); + }); + + await waitFor(() => { + expect(result.current.repositories.size).toBe(1); + }); + expect(result.current.repositories.has("/home/user/project-x")).toBe(true); + }); +}); diff --git a/site/src/pages/AgentsPage/useGitWatcher.ts b/site/src/pages/AgentsPage/useGitWatcher.ts new file mode 100644 index 0000000000..3cbe0f8136 --- /dev/null +++ b/site/src/pages/AgentsPage/useGitWatcher.ts @@ -0,0 +1,132 @@ +import { watchChatGit } from "api/api"; +import type { + WorkspaceAgentGitClientMessage, + WorkspaceAgentGitServerMessage, + WorkspaceAgentRepoChanges, +} from "api/typesGenerated"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface UseGitWatcherOptions { + chatId: string | undefined; +} + +interface UseGitWatcherResult { + /** Current repo state, keyed by repo root path. */ + repositories: ReadonlyMap; + /** Whether the WebSocket is currently connected. */ + isConnected: boolean; + /** Send a refresh request. */ + refresh: () => void; +} + +const MAX_BACKOFF_MS = 30_000; + +export function useGitWatcher({ + chatId, +}: UseGitWatcherOptions): UseGitWatcherResult { + const [repositories, setRepositories] = useState< + ReadonlyMap + >(new Map()); + const [isConnected, setIsConnected] = useState(false); + + const socketRef = useRef(null); + const reconnectAttemptRef = useRef(0); + const reconnectTimerRef = useRef | null>(null); + // Track whether we've been disposed to avoid reconnecting after unmount. + const disposedRef = useRef(false); + + const sendMessage = useCallback((msg: WorkspaceAgentGitClientMessage) => { + const socket = socketRef.current; + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(msg)); + } + }, []); + + const refresh = useCallback(() => { + sendMessage({ type: "refresh" }); + }, [sendMessage]); + + useEffect(() => { + if (!chatId) { + return; + } + + disposedRef.current = false; + + function connect() { + if (disposedRef.current) { + return; + } + + const socket = watchChatGit(chatId!); + socketRef.current = socket; + + socket.addEventListener("open", () => { + setIsConnected(true); + reconnectAttemptRef.current = 0; + }); + + socket.addEventListener("message", (event) => { + try { + const data = JSON.parse( + String(event.data), + ) as WorkspaceAgentGitServerMessage; + + if (data.type === "changes" && data.repositories) { + setRepositories((prev) => { + const next = new Map(prev); + for (const repo of data.repositories!) { + if (repo.removed) { + next.delete(repo.repo_root); + } else { + next.set(repo.repo_root, repo); + } + } + return next; + }); + } else if (data.type === "error") { + console.warn("[useGitWatcher] server error:", data.message); + } + } catch { + // Ignore unparsable messages. + } + }); + + // Note: WebSocket "error" events are always followed by a "close" + // event, so reconnection is handled here. + socket.addEventListener("close", () => { + setIsConnected(false); + socketRef.current = null; + + if (disposedRef.current) { + return; + } + + // Reconnect with exponential backoff. + const attempt = reconnectAttemptRef.current; + const delay = Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS); + reconnectAttemptRef.current = attempt + 1; + reconnectTimerRef.current = setTimeout(connect, delay); + }); + } + + connect(); + + return () => { + disposedRef.current = true; + if (reconnectTimerRef.current !== null) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + if (socketRef.current) { + socketRef.current.close(); + socketRef.current = null; + } + setIsConnected(false); + setRepositories(new Map()); + reconnectAttemptRef.current = 0; + }; + }, [chatId]); + + return { repositories, isConnected, refresh }; +} diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 40201923e7..eea61b0222 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -74,28 +74,64 @@ export const withDashboardProvider = ( type MessageEvent = Record<"data", string>; type CallbackFn = (ev?: MessageEvent) => void; +// parameters.webSocket accepts two formats: +// +// Array — events are delivered to every socket (backward-compatible): +// webSocket: [{ event: "message", data: "..." }] +// +// Record keyed by URL substring — events are delivered only to +// sockets whose URL contains the key: +// webSocket: { +// "/api/experimental/chats/": [{ event: "message", data: "..." }], +// "/api/experimental/workspaceagents/": [{ event: "message", data: "..." }], +// } export const withWebSocket = (Story: FC, { parameters }: StoryContext) => { - const events = parameters.webSocket; + const param = parameters.webSocket; - if (!events) { + if (!param) { console.warn("You forgot to add `parameters.webSocket` to your story"); return ; } - const listeners = new Map(); - let callEventsDelay: number; + const isRouted = !Array.isArray(param); + const broadcastEvents = isRouted ? [] : param; + const routedEvents = isRouted ? param : {}; window.WebSocket = class WebSocket { public readyState = 1; + public binaryType = "blob"; + + #listeners = new Map(); + #callEventsDelay: number | undefined; + #url: string; + + constructor(url?: string) { + this.#url = url ?? ""; + } + + send() {} addEventListener(type: string, callback: CallbackFn) { - listeners.set(type, callback); + this.#listeners.set(type, callback); + + // Determine which events this socket should receive. + let events = broadcastEvents; + if (isRouted) { + const matchingKey = Object.keys(routedEvents).find((key) => + this.#url.includes(key), + ); + events = matchingKey ? routedEvents[matchingKey] : []; + } + + if (events.length === 0) { + return; + } // Runs when the last event listener is added - clearTimeout(callEventsDelay); - callEventsDelay = window.setTimeout(() => { + clearTimeout(this.#callEventsDelay); + this.#callEventsDelay = window.setTimeout(() => { for (const entry of events) { - const callback = listeners.get(entry.event); + const callback = this.#listeners.get(entry.event); if (callback) { entry.event === "message" diff --git a/site/tailwind.config.js b/site/tailwind.config.js index 7dbfaa2ed3..7b46f228c6 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -101,10 +101,17 @@ module.exports = { "0%": { left: "0%" }, "100%": { left: "100%" }, }, + "zip-right": { + "0%": { left: "0%", width: "0%" }, + "30%": { left: "0%", width: "40%" }, + "100%": { left: "100%", width: "0%" }, + }, }, animation: { loading: "loading 2s ease-in-out infinite alternate", "caret-scan": "caret-scan 3s ease-in-out infinite", + "spin-once": "spin 1s cubic-bezier(0.4, 0, 0.2, 1)", + "zip-right": "zip-right 1s cubic-bezier(0.4, 0, 0.2, 1)", }, }, },