diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index ad29a775bc..43733dfaaf 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -26,6 +26,7 @@ import { import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { isDevcontainerRuntime } from "@/common/types/runtime"; import { getWorkspaceLastReadKey } from "@/common/constants/storage"; +import type { GitHubRepoInfo } from "@/common/orpc/schemas/githubRepoInfo"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import React, { useState, useEffect, useRef, useCallback } from "react"; import { useDrag, useDrop } from "react-dnd"; @@ -64,6 +65,7 @@ import { ChevronDown, HeartPulse, Pin, + Folder, } from "lucide-react"; import { isWorkspacePinnable, isWorkspacePinned } from "@/common/utils/pin"; import { WorkspaceStatusIndicator } from "../WorkspaceStatusIndicator/WorkspaceStatusIndicator"; @@ -111,6 +113,8 @@ interface AgentListItemBaseProps { /** Props for regular (persisted) workspace items */ export interface AgentListItemProps extends AgentListItemBaseProps { variant?: "workspace"; + presentation?: "default" | "flat-card"; + githubRepoInfo?: GitHubRepoInfo | null; metadata: FrontendWorkspaceMetadata; projectName: string; subAgentConnectorLayout?: SubAgentConnectorLayout; @@ -211,6 +215,37 @@ function HeartbeatFallbackIcon() { ); } +function ProjectAvatar(props: { info: GitHubRepoInfo | null | undefined; label: string }) { + const url = props.info?.avatarUrl; + const [failedUrl, setFailedUrl] = useState(null); + const failed = failedUrl === url; + + if (!url || failed) { + return ( +
+
+ ); + } + + return ( + setFailedUrl(null)} + onError={() => setFailedUrl(url)} + /> + ); +} + function formatSubAgentCount(count: number, label: "active" | "queued"): string { return `${count} sub-agent${count === 1 ? "" : "s"} ${label}`; } @@ -482,6 +517,8 @@ function RegularAgentListItemInner(props: AgentListItemProps) { // Destructure metadata for convenience const { id: workspaceId, namedWorkspacePath } = metadata; const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS); + const isFlatCard = props.presentation === "flat-card"; + const repositoryLabel = props.githubRepoInfo?.repo ?? projectName; const isInitializing = metadata.isInitializing === true; const isRemoving = isRemovingProp === true || metadata.isRemoving === true; const isDisabled = isRemoving || isArchiving === true; @@ -755,7 +792,7 @@ function RegularAgentListItemInner(props: AgentListItemProps) { ? "text-content-tertiary" : "text-content-primary"; - const paddingLeft = getSidebarItemPaddingLeft(depth); + const paddingLeft = isFlatCard ? 8 : getSidebarItemPaddingLeft(depth); const workspaceSelection: WorkspaceSelection = { projectPath, @@ -861,7 +898,9 @@ function RegularAgentListItemInner(props: AgentListItemProps) { !isArchiving && "pl-1 hover:bg-surface-secondary [&:hover_button]:opacity-100", isArchiving && "pointer-events-none opacity-70", isDisabled ? "cursor-default" : "cursor-pointer", - isSelected && !isDisabled && "bg-surface-secondary" + isSelected && !isDisabled && "bg-surface-secondary", + isFlatCard && + "border-border-light mx-2 mb-1.5 rounded-md border bg-surface-primary pr-2 shadow-sm" )} style={{ paddingLeft }} onClick={(event) => { @@ -1175,8 +1214,15 @@ function RegularAgentListItemInner(props: AgentListItemProps) { ) )} + {isFlatCard && } + {/* Keep title row anchored so status dot/title align across single+double-line states. */}
+ {isFlatCard && ( +
+ {repositoryLabel} +
+ )}
) { }; } -/** Single project with multiple workspaces including SSH */ +const STORY_AVATAR_DATA_URL = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' rx='32' fill='%235b8def'/%3E%3Cpath d='M20 44V20h24v24z' fill='white'/%3E%3C/svg%3E"; + +function createFlatCardsClient(options: { + ageGrouping: boolean; + brokenAvatar?: boolean; +}): APIClient { + const firstProjectPath = "/home/user/projects/extraordinarily-long-repository-name"; + const secondProjectPath = "/home/user/projects/sidebar-fallbacks"; + const recentWorkspace = createWorkspace({ + id: "ws-flat-card-recent", + name: "recent-card", + title: "Implement a very long workspace title that must truncate without hiding actions", + projectName: "extraordinarily-long-repository-name", + projectPath: firstProjectPath, + createdAt: new Date(NOW - 30 * 60 * 1000).toISOString(), + }); + const startingWorkspace = { + ...createWorkspace({ + id: "ws-flat-card-starting", + name: "starting-card", + title: "Starting workspace with status label", + projectName: "extraordinarily-long-repository-name", + projectPath: firstProjectPath, + createdAt: new Date(NOW - 60 * 60 * 1000).toISOString(), + }), + isInitializing: true, + }; + const oldWorkspace = createWorkspace({ + id: "ws-flat-card-old", + name: "old-card", + title: "Older workspace using the fallback repository icon", + projectName: "sidebar-fallbacks", + projectPath: secondProjectPath, + createdAt: new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString(), + }); + const scratchWorkspace = { + ...createWorkspace({ + id: "ws-flat-card-scratch", + name: "scratch-card", + title: "Scratch workspace", + projectName: "Scratch", + projectPath: firstProjectPath, + createdAt: new Date(NOW - 90 * 60 * 1000).toISOString(), + }), + kind: "scratch" as const, + }; + const workspaces = [recentWorkspace, startingWorkspace, oldWorkspace, scratchWorkspace]; + + setWorkspaceDrafts(firstProjectPath, [ + { + draftId: "flat-card-draft", + workspaceName: "Draft workspace", + prompt: "Draft prompt preview", + createdAt: NOW - 1_000, + }, + ]); + + updatePersistedState(SIDEBAR_DISPLAY_STYLE_KEY, "flat"); + updatePersistedState(SIDEBAR_AGE_GROUPING_KEY, options.ageGrouping); + updatePersistedState("expandedOldWorkspaces", { "flat:0": true }); + + return createMockORPCClient({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + githubRepoInfoByProject: { + [firstProjectPath]: { + owner: "coder", + repo: "extraordinarily-long-repository-name", + avatarUrl: STORY_AVATAR_DATA_URL, + }, + [secondProjectPath]: options.brokenAvatar + ? { + owner: "broken-avatar", + repo: "sidebar-fallbacks", + avatarUrl: "https://example.invalid/sidebar-avatar.png", + } + : null, + }, + }); +} + export const SingleProject: AppStory = { parameters: { pixel: { matrix: PIXEL_DUAL_THEME }, @@ -991,6 +1074,75 @@ export const FlatListWhenAgeGroupingDisabled: AppStory = { }, }; +export const FlatCardsWithAgeGrouping: AppStory = { + parameters: { + pixel: { matrix: PIXEL_DUAL_THEME }, + }, + render: () => ( + createFlatCardsClient({ ageGrouping: true })} /> + ), + play: async ({ canvasElement }) => { + await waitFor(() => { + if (!canvasElement.querySelector('[data-testid="flat-sidebar-list"]')) { + throw new Error("Flat sidebar cards did not render"); + } + if (!canvasElement.querySelector('img[src^="data:image/svg+xml"]')) { + throw new Error("Repository avatar did not render after identity lookup"); + } + if (!canvasElement.querySelector('[data-draft-id="flat-card-draft"]')) { + throw new Error("Flat sidebar draft did not render"); + } + if (!canvasElement.querySelector('[data-workspace-id="ws-flat-card-old"]')) { + throw new Error("Expanded age tier did not render the old flat card"); + } + }); + }, +}; + +export const FlatCardsWithoutAgeGroupingAndAvatarFallbacks: AppStory = { + render: () => ( + createFlatCardsClient({ ageGrouping: false, brokenAvatar: true })} + /> + ), + play: async ({ canvasElement }) => { + await waitFor(() => { + if (canvasElement.querySelector('[aria-expanded][class*="border-t"]')) { + throw new Error("Age tier rendered while grouping was disabled"); + } + if (!canvasElement.querySelector('[data-testid="project-avatar-fallback"]')) { + throw new Error("Avatar fallback did not render"); + } + }); + + const actions = within(canvasElement).getByRole("button", { + name: /workspace actions for implement a very long workspace title/i, + }); + await userEvent.click(actions); + await waitFor(() => { + within(document.body).getByText("Archive chat"); + }); + }, +}; + +export const FlatCardsPhone: AppStory = { + tags: ["!test"], + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + parameters: { + pixel: { + matrix: { themes: ["dark", "light"], viewports: ["phone"] }, + }, + }, + render: () => ( + createFlatCardsClient({ ageGrouping: false, brokenAvatar: true })} + /> + ), +}; + /** Long workspace names - tests truncation and prevents horizontal scroll regression */ export const LongWorkspaceNames: AppStory = { render: () => ( diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 12e10555a2..ff2e5f3562 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -16,7 +16,10 @@ import { useWorkspaceStoreRaw, type WorkspaceStore } from "@/browser/stores/Work import { EXPANDED_PROJECTS_KEY, MOBILE_LEFT_SIDEBAR_SCROLL_TOP_KEY, + DEFAULT_SIDEBAR_DISPLAY_STYLE, SIDEBAR_AGE_GROUPING_KEY, + SIDEBAR_DISPLAY_STYLE_KEY, + normalizeSidebarDisplayStyle, getDraftScopeId, getInputAttachmentsKey, getInputKey, @@ -93,6 +96,7 @@ import { getTaskGroupMemberDepth, } from "../sidebarItemLayout"; import { TaskGroupListItem } from "./TaskGroupListItem"; +import { buildFlatWorkspaceList, type FlatWorkspaceRow } from "./flatWorkspaceList"; import { collectActiveWorkflowGroupKeys, computeSidebarTaskGroups, @@ -893,6 +897,15 @@ const ProjectSidebarInner: React.FC = ({ const [ageGroupingEnabled] = usePersistedState(SIDEBAR_AGE_GROUPING_KEY, true, { listener: true, }); + const [rawSidebarDisplayStyle] = usePersistedState( + SIDEBAR_DISPLAY_STYLE_KEY, + DEFAULT_SIDEBAR_DISPLAY_STYLE, + { listener: true } + ); + const sidebarDisplayStyle = normalizeSidebarDisplayStyle(rawSidebarDisplayStyle); + const [githubRepoInfoByProject, setGitHubRepoInfoByProject] = useState< + Awaited["projects"]["githubRepoInfo"]>> + >({}); // Track which sections are expanded const [expandedSections, setExpandedSections] = usePersistedState>( @@ -1739,6 +1752,28 @@ const ProjectSidebarInner: React.FC = ({ return keys.join("\u0001"); // use non-printable separator }, [userProjects]); + useEffect(() => { + if (sidebarDisplayStyle !== "flat" || !api) { + return; + } + let cancelled = false; + void api.projects + .githubRepoInfo() + .then((info) => { + if (!cancelled) { + setGitHubRepoInfoByProject(info); + } + }) + .catch(() => { + if (!cancelled) { + setGitHubRepoInfoByProject({}); + } + }); + return () => { + cancelled = true; + }; + }, [api, projectPathsSignature, sidebarDisplayStyle]); + // Normalize order when the set of projects changes (not on every parent render) useEffect(() => { // Skip normalization if projects haven't loaded yet (empty Map on initial render) @@ -1847,6 +1882,41 @@ const ProjectSidebarInner: React.FC = ({ MULTI_PROJECT_SIDEBAR_SECTION_ID ); + const flatWorkspaceRows = + sidebarDisplayStyle === "flat" + ? buildFlatWorkspaceList({ + sortedWorkspacesByProject, + workspaceRecency, + userProjects, + githubRepoInfoByProject, + multiProjectWorkspacesEnabled, + }) + : []; + const flatWorkspaceMetadata = flatWorkspaceRows.map((row) => row.metadata); + for (const key of collectActiveWorkflowGroupKeys(flatWorkspaceMetadata, { + isWorkspaceLiveActive, + })) { + sessionActiveTaskGroupKeysRef.current.add(key); + } + const visibleFlatWorkspaceMetadata = ensureWorkflowGroupMembersVisible({ + allRows: flatWorkspaceMetadata, + visibleRows: filterVisibleAgentRows(flatWorkspaceMetadata, expandedCompletedParentIds), + sessionActiveGroupKeys: sessionActiveTaskGroupKeysRef.current, + }); + const flatDepthByWorkspaceId = computeWorkspaceDepthMap(flatWorkspaceMetadata); + const flatRowMetaByWorkspaceId = computeAgentRowRenderMeta( + flatWorkspaceMetadata, + flatDepthByWorkspaceId, + expandedCompletedParentIds + ); + const flatTaskGroups = computeSidebarTaskGroups({ + rows: visibleFlatWorkspaceMetadata, + allRows: flatWorkspaceMetadata, + selectedWorkspaceId: selectedWorkspace?.workspaceId, + isWorkspaceLiveActive, + }); + const flatRowByWorkspaceId = new Map(flatWorkspaceRows.map((row) => [row.metadata.id, row])); + const handleReorder = useCallback( (draggedPath: string, targetPath: string) => { const next = reorderProjects(projectOrder, userProjects, draggedPath, targetPath); @@ -1982,6 +2052,218 @@ const ProjectSidebarInner: React.FC = ({ : archiveConfirmation?.untrackedPaths; const archiveConfirmationIsStreaming = archiveConfirmation?.isStreaming ?? false; + const flatDraftRows = + sidebarDisplayStyle === "flat" + ? Object.entries(workspaceDraftsByProject) + .flatMap(([projectPath, drafts]) => drafts.map((draft) => ({ projectPath, draft }))) + .sort((left, right) => right.draft.createdAt - left.draft.createdAt) + : []; + + const hasVisibleFlatDrafts = flatDraftRows.some(({ projectPath, draft }) => { + const reactiveVisibility = draftVisibilityByProject[projectPath]?.[draft.draftId]; + return reactiveVisibility ?? isDraftVisible(projectPath, draft.draftId); + }); + + const renderFlatDraft = ( + entry: (typeof flatDraftRows)[number], + index: number + ): React.ReactNode => { + const { projectPath, draft } = entry; + const isSelected = + pendingNewWorkspaceProject === projectPath && pendingNewWorkspaceDraftId === draft.draftId; + return ( + { + handleDraftVisibilityChange(projectPath, draft.draftId, isVisible); + }} + onOpen={() => handleOpenWorkspaceDraft(projectPath, draft.draftId)} + onDelete={() => { + if (isSelected) { + const fallback = flatDraftRows[index + 1] ?? flatDraftRows[index - 1]; + if (fallback) { + openWorkspaceDraft(fallback.projectPath, fallback.draft.draftId); + } else { + navigateToProject(projectPath); + } + } + deleteWorkspaceDraft(projectPath, draft.draftId); + }} + /> + ); + }; + + // Same group keys as the section renderer, so flat-mode drags reorder + // within the same pinned block that project mode (and the backend order) + // uses. row.projectPath differs from metadata.projectPath only when it is + // the resolved sub-project section. + const getFlatPinnedReorderGroup = (row: FlatWorkspaceRow): string => { + if (row.metadata.kind === "scratch") { + return SCRATCH_PINNED_REORDER_GROUP; + } + if (isMultiProject(row.metadata)) { + return MULTI_PROJECT_PINNED_REORDER_GROUP; + } + const sectionId = + row.projectPath !== null && row.projectPath !== row.metadata.projectPath + ? row.projectPath + : undefined; + return getPinnedReorderGroup(row.metadata.projectPath, sectionId); + }; + + const renderFlatWorkspace = ( + row: FlatWorkspaceRow, + keyOverride?: string, + taskGroupHeaderTitle?: string + ) => { + const metadata = row.metadata; + const rowRenderMeta = flatRowMetaByWorkspaceId.get(metadata.id); + return ( + + ); + }; + + const renderFlatRows = (rows: FrontendWorkspaceMetadata[]): React.ReactNode[] => { + const rendered: React.ReactNode[] = []; + for (const workspace of rows) { + const row = flatRowByWorkspaceId.get(workspace.id); + if (!row) { + continue; + } + const groupKey = flatTaskGroups.memberGroupStorageKeyByWorkspaceId.get(workspace.id); + const group = groupKey ? flatTaskGroups.groupsByStorageKey.get(groupKey) : undefined; + if (!group) { + rendered.push(renderFlatWorkspace(row)); + continue; + } + if (group.anchorId !== workspace.id) { + continue; + } + + if (group.kind === "workflow" && group.hasActiveMember) { + sessionActiveTaskGroupKeysRef.current.add(group.storageKey); + } + const defaultExpanded = + group.kind === "workflow" && + (group.hasActiveMember || sessionActiveTaskGroupKeysRef.current.has(group.storageKey)); + const isExpanded = expandedTaskGroups[group.storageKey] ?? defaultExpanded; + const isGroupSelected = group.allMembers.some( + (member) => member.id === selectedWorkspace?.workspaceId + ); + rendered.push( + toggleTaskGroupExpansion(group.storageKey, isExpanded)} + onArchiveAll={ + group.kind === "variants" + ? (buttonElement) => + handleArchiveVariantGroup(group.title, group.allMembers, buttonElement) + : undefined + } + /> + ); + if (isExpanded) { + for (const member of group.displayMembers) { + const memberRow = flatRowByWorkspaceId.get(member.id); + if (memberRow) { + rendered.push( + renderFlatWorkspace( + memberRow, + `flat-task-group-member:${group.storageKey}:${member.id}`, + group.title + ) + ); + } + } + } + } + return rendered; + }; + + const flatAgePartition = ageGroupingEnabled + ? partitionWorkspacesByAge(visibleFlatWorkspaceMetadata, workspaceRecency) + : { recent: visibleFlatWorkspaceMetadata, buckets: AGE_THRESHOLDS_DAYS.map(() => []) }; + const renderFlatAgeTier = (tierIndex: number): React.ReactNode => { + const remainingCount = flatAgePartition.buckets + .slice(tierIndex) + .reduce((sum, bucket) => sum + bucket.length, 0); + if (remainingCount === 0) { + return null; + } + const tierKey = `flat:${tierIndex}`; + const isExpanded = expandedOldWorkspaces[tierKey] ?? false; + const displayCount = isExpanded ? flatAgePartition.buckets[tierIndex].length : remainingCount; + const thresholdLabel = formatDaysThreshold(AGE_THRESHOLDS_DAYS[tierIndex]); + const nextTier = findNextNonEmptyTier(flatAgePartition.buckets, tierIndex + 1); + return ( + + + {isExpanded && ( + <> + {renderFlatRows(flatAgePartition.buckets[tierIndex])} + {nextTier !== -1 && renderFlatAgeTier(nextTier)} + + )} + + ); + }; + const firstFlatAgeTier = findNextNonEmptyTier(flatAgePartition.buckets, 0); + return ( = ({ viewportClassName="overflow-x-hidden" >
-
- -
- Chats - {(scratchWorkspaces.length > 0 || scratchDrafts.length > 0) && ( - - ({scratchWorkspaces.length + scratchDrafts.length}) - + {sidebarDisplayStyle === "flat" ? ( +
+ {flatWorkspaceMetadata.length === 0 && !hasVisibleFlatDrafts ? ( +
+

No chats

+ +
+ ) : ( + <> + {flatDraftRows.map(renderFlatDraft)} + {renderFlatRows(flatAgePartition.recent)} + {firstFlatAgeTier !== -1 && renderFlatAgeTier(firstFlatAgeTier)} + )}
- - + ) : ( + <> +
- - New scratch chat - -
- {isScratchSectionExpanded && ( -
- {scratchDrafts.map((draft, index) => { - const isSelected = - pendingNewWorkspaceProject === SCRATCH_PROJECT_CONFIG_KEY && - pendingNewWorkspaceDraftId === draft.draftId; - return ( - { - handleDraftVisibilityChange( - SCRATCH_PROJECT_CONFIG_KEY, - draft.draftId, - isVisible - ); - }} - onOpen={() => - handleOpenWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY, draft.draftId) - } - onDelete={() => { - if (isSelected) { - navigateToProject(SCRATCH_PROJECT_CONFIG_KEY); - } - deleteWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY, draft.draftId); - }} - /> - ); - })} - {visibleScratchWorkspaces.map((metadata) => { - const rowRenderMeta = scratchRowMetaByWorkspaceId.get(metadata.id); - return ( - - ); - })} - {scratchWorkspaces.length === 0 && scratchDrafts.length === 0 && ( - - )} -
- )} -
- - {multiProjectWorkspaces.length > 0 && ( -
-
- +
+ + Chats + + {(scratchWorkspaces.length > 0 || scratchDrafts.length > 0) && ( + + ({scratchWorkspaces.length + scratchDrafts.length}) + )} - - -
- - Multi-Project - - - ({multiProjectWorkspaces.length}) - +
+ + + + + New scratch chat +
-
- {isMultiProjectSectionExpanded && ( -
- {visibleMultiProjectWorkspaces.map((metadata) => { - const rowRenderMeta = multiProjectRowMetaByWorkspaceId.get(metadata.id); - - return ( - - ); - })} -
- )} -
- )} - - {sortedProjectPaths.length === 0 && multiProjectWorkspaces.length === 0 ? ( -
-

No projects

-
- - -
-
- ) : ( - sortedProjectPaths.map((projectPath) => { - const config = userProjects.get(projectPath); - if (!config) return null; - const projectFolderColor = config.color - ? resolveSectionColor(config.color) - : undefined; - const projectName = getProjectNameFromPath(projectPath); - const sanitizedProjectId = - projectPath.replace(/[^a-zA-Z0-9_-]/g, "-") || "root"; - const workspaceListId = `workspace-list-${sanitizedProjectId}`; - const isExpanded = expandedProjectsList.includes(projectPath); - const displayProjectName = - config.displayName ?? getProjectFallbackLabel(projectPath); - const isEditingProjectDisplayName = editingProjectPath === projectPath; - const projectWorkspaces = - singleProjectWorkspacesByProject.get(projectPath) ?? []; - const projectAgentCount = projectWorkspaces.length; - const projectHasAttention = projectWorkspaces.some( - (workspace) => workspaceAttentionById.get(workspace.id) === true - ); - - return ( -
- { - if (projectContextMenu.suppressClickIfLongPress()) { - return; - } - if (isEditingProjectDisplayName) { - return; - } - handleAddWorkspace(projectPath); - }} - onContextMenu={(event) => handleOpenProjectMenu(event, projectPath)} - onTouchStart={(event) => - handleProjectContextMenuTouchStart(event, projectPath) - } - onTouchEnd={projectContextMenu.touchHandlers.onTouchEnd} - onTouchMove={projectContextMenu.touchHandlers.onTouchMove} - onKeyDown={(e: React.KeyboardEvent) => { - // Ignore key events from child buttons - if (e.target instanceof HTMLElement && e.target !== e.currentTarget) { - return; - } - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleAddWorkspace(projectPath); - } - }} - role="button" - tabIndex={0} - aria-expanded={isExpanded} - aria-controls={workspaceListId} - aria-label={`Create workspace in ${projectName}`} - data-project-path={projectPath} - > - -
handleOpenProjectMenu(event, projectPath)} - > - - - {isEditingProjectDisplayName ? ( - event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} - onContextMenu={(event) => event.stopPropagation()} - onChange={(event) => { - setEditingProjectDisplayName(event.target.value); - }} - onKeyDown={(event) => { - stopKeyboardPropagation(event); - if (event.key === "Escape") { - event.preventDefault(); - skipNextProjectNameBlurCommitRef.current = true; - cancelProjectDisplayNameEditing(); - return; - } + deleteWorkspaceDraft(SCRATCH_PROJECT_CONFIG_KEY, draft.draftId); + }} + /> + ); + })} + {visibleScratchWorkspaces.map((metadata) => { + const rowRenderMeta = scratchRowMetaByWorkspaceId.get(metadata.id); + return ( + + ); + })} + {scratchWorkspaces.length === 0 && scratchDrafts.length === 0 && ( + + )} +
+ )} - if (event.key === "Enter") { - event.preventDefault(); - event.currentTarget.blur(); - } - }} - onBlur={(event) => { - event.stopPropagation(); - if (skipNextProjectNameBlurCommitRef.current) { - skipNextProjectNameBlurCommitRef.current = false; - return; - } - void commitProjectDisplayNameEdit( - projectPath, - event.currentTarget.value - ); - }} - /> + {multiProjectWorkspaces.length > 0 && ( +
+
+ +
+ + Multi-Project + + + ({multiProjectWorkspaces.length}) + +
- - - - - - New chat ({formatKeybind(KEYBINDS.NEW_WORKSPACE)}) - - - - - - - Project options - - - - {isExpanded && ( -
- {(() => { - // Archived workspaces are excluded from workspaceMetadata so won't appear here - - const draftsForProject = workspaceDraftsByProject[projectPath] ?? []; - const activeDraftIds = new Set( - draftsForProject.map((draft) => draft.draftId) - ); - const draftPromotionsForProject = - workspaceDraftPromotionsByProject[projectPath] ?? {}; - const activeDraftPromotions = Object.fromEntries( - Object.entries(draftPromotionsForProject).filter(([draftId]) => - activeDraftIds.has(draftId) - ) - ); - const promotedWorkspaceIds = new Set( - Object.values(activeDraftPromotions).map((metadata) => metadata.id) - ); - const workspacesForNormalRendering = projectWorkspaces.filter( - (workspace) => !promotedWorkspaceIds.has(workspace.id) - ); - const sections: SectionConfig[] = getSubProjectsForParent( - projectPath, - userProjects - ).map(([subProjectPath, subProjectConfig]) => ({ - id: subProjectPath, - name: getProjectDisplayName(subProjectPath, subProjectConfig), - color: subProjectConfig.color, - })); - const depthByWorkspaceId = - computeWorkspaceDepthMap(projectWorkspaces); - // Track runs that are (or were, this session) active so their - // groups stay mounted across step gaps where every member is - // momentarily terminal (no flash-out between sequential steps). - for (const key of collectActiveWorkflowGroupKeys( - workspacesForNormalRendering, - { isWorkspaceLiveActive } - )) { - sessionActiveTaskGroupKeysRef.current.add(key); - } - const visibleWorkspacesForNormalRendering = - ensureWorkflowGroupMembersVisible({ - allRows: workspacesForNormalRendering, - visibleRows: filterVisibleAgentRows( - workspacesForNormalRendering, - expandedCompletedParentIds - ), - sessionActiveGroupKeys: sessionActiveTaskGroupKeysRef.current, - }); - const baseRowMetaByWorkspaceId = computeAgentRowRenderMeta( - workspacesForNormalRendering, - depthByWorkspaceId, - expandedCompletedParentIds - ); - const sortedDrafts = draftsForProject - .slice() - .sort((a, b) => b.createdAt - a.createdAt); - const draftVisibilityForProject = - draftVisibilityByProject[projectPath] ?? {}; - const hasVisibleDrafts = sortedDrafts.some((draft) => { - const reactiveVisibility = draftVisibilityForProject[draft.draftId]; - return ( - reactiveVisibility ?? isDraftVisible(projectPath, draft.draftId) + {isMultiProjectSectionExpanded && ( +
+ {visibleMultiProjectWorkspaces.map((metadata) => { + const rowRenderMeta = multiProjectRowMetaByWorkspaceId.get( + metadata.id ); - }); - const projectHasNoAgentsOrDrafts = - projectWorkspaces.length === 0 && !hasVisibleDrafts; - const draftNumberById = new Map( - sortedDrafts.map( - (draft, index) => [draft.draftId, index + 1] as const - ) - ); - const getDraftSectionId = ( - draft: (typeof sortedDrafts)[number] - ): string | null => - typeof draft.subProjectPath === "string" && - userProjects.get(draft.subProjectPath)?.parentProjectPath === - projectPath - ? draft.subProjectPath - : null; - - // Drafts can reference a section that has since been deleted. - // Treat those as unsectioned so they remain accessible. - const unsectionedDrafts: typeof sortedDrafts = []; - const draftsBySectionId = new Map(); - for (const draft of sortedDrafts) { - const sectionId = getDraftSectionId(draft); - if (sectionId === null) { - unsectionedDrafts.push(draft); - continue; - } - - const existing = draftsBySectionId.get(sectionId); - if (existing) { - existing.push(draft); - } else { - draftsBySectionId.set(sectionId, [draft]); - } - } - - const renderWorkspace = ( - metadata: FrontendWorkspaceMetadata, - sectionId?: string, - rowRenderMetaOverride?: AgentRowRenderMeta | null, - depthOverride?: number, - keyOverride?: string, - subAgentConnectorLayout?: "default" | "task-group-member", - taskGroupHeaderTitle?: string - ) => { - const rowRenderMeta = - rowRenderMetaOverride === undefined - ? baseRowMetaByWorkspaceId.get(metadata.id) - : (rowRenderMetaOverride ?? undefined); return ( = ({ } onSelectWorkspace={handleSelectWorkspace} onForkWorkspace={handleForkWorkspace} - onStopRuntime={handleStopRuntime} onArchiveWorkspace={handleArchiveWorkspace} onCancelCreation={handleCancelWorkspaceCreation} depth={ - depthOverride ?? rowRenderMeta?.depth ?? - depthByWorkspaceId[metadata.id] ?? + multiProjectDepthByWorkspaceId[metadata.id] ?? 0 } - sectionId={sectionId} - pinnedReorderGroup={getPinnedReorderGroup( - projectPath, - sectionId - )} + pinnedReorderGroup={MULTI_PROJECT_PINNED_REORDER_GROUP} onPinnedReorderDrop={handlePinnedReorderDrop} rowRenderMeta={rowRenderMeta} - subAgentConnectorLayout={subAgentConnectorLayout} - taskGroupHeaderTitle={taskGroupHeaderTitle} delegatedActivity={delegatedActivityByWorkspaceId.get( metadata.id )} @@ -2601,636 +2529,1110 @@ const ProjectSidebarInner: React.FC = ({ onToggleCompletedChildren={toggleCompletedChildrenExpansion} /> ); - }; - - const renderWorkspaceRowsWithTaskGroupCoalescing = ({ - rows, - sectionId, - rowMetaByWorkspaceId, - taskGroups, - memberMetaByWorkspaceId, - }: { - rows: FrontendWorkspaceMetadata[]; - sectionId?: string; - rowMetaByWorkspaceId: ReadonlyMap; - taskGroups: SidebarTaskGroupsResult; - memberMetaByWorkspaceId: ReadonlyMap; - }): React.ReactNode[] => { - const renderedRows: React.ReactNode[] = []; - - for (const workspace of rows) { - const groupKey = - taskGroups.memberGroupStorageKeyByWorkspaceId.get(workspace.id); - const group = - groupKey != null - ? taskGroups.groupsByStorageKey.get(groupKey) - : undefined; - if (group == null) { - renderedRows.push( - renderWorkspace( - workspace, - sectionId, - rowMetaByWorkspaceId.get(workspace.id) - ) - ); - continue; - } - - if (group.anchorId !== workspace.id) { - // Non-anchor members render under the group header at the - // anchor's position (D5), so suppress them here. - continue; - } + })} +
+ )} +
+ )} - const headerMeta = rowMetaByWorkspaceId.get(group.storageKey); - const headerDepth = - headerMeta?.depth ?? depthByWorkspaceId[workspace.id] ?? 0; + {sortedProjectPaths.length === 0 && multiProjectWorkspaces.length === 0 ? ( +
+

No projects

+
+ + +
+
+ ) : ( + sortedProjectPaths.map((projectPath) => { + const config = userProjects.get(projectPath); + if (!config) return null; + const projectFolderColor = config.color + ? resolveSectionColor(config.color) + : undefined; + const projectName = getProjectNameFromPath(projectPath); + const sanitizedProjectId = + projectPath.replace(/[^a-zA-Z0-9_-]/g, "-") || "root"; + const workspaceListId = `workspace-list-${sanitizedProjectId}`; + const isExpanded = expandedProjectsList.includes(projectPath); + const displayProjectName = + config.displayName ?? getProjectFallbackLabel(projectPath); + const isEditingProjectDisplayName = editingProjectPath === projectPath; + const projectWorkspaces = + singleProjectWorkspacesByProject.get(projectPath) ?? []; + const projectAgentCount = projectWorkspaces.length; + const projectHasAttention = projectWorkspaces.some( + (workspace) => workspaceAttentionById.get(workspace.id) === true + ); - // D6: groups seen active this session keep defaulting to - // expanded - no live auto-collapse on completion. An explicit - // (persisted) user toggle always wins. - if (group.kind === "workflow" && group.hasActiveMember) { - sessionActiveTaskGroupKeysRef.current.add(group.storageKey); + return ( +
+ { + if (projectContextMenu.suppressClickIfLongPress()) { + return; } - const defaultExpanded = - group.kind === "workflow" && - (group.hasActiveMember || - sessionActiveTaskGroupKeysRef.current.has(group.storageKey)); - const isExpanded = - expandedTaskGroups[group.storageKey] ?? defaultExpanded; - const isGroupSelected = group.allMembers.some( - (member) => member.id === selectedWorkspace?.workspaceId - ); - - const headerRow = ( - { - toggleTaskGroupExpansion(group.storageKey, isExpanded); + if (isEditingProjectDisplayName) { + return; + } + handleAddWorkspace(projectPath); + }} + onContextMenu={(event) => handleOpenProjectMenu(event, projectPath)} + onTouchStart={(event) => + handleProjectContextMenuTouchStart(event, projectPath) + } + onTouchEnd={projectContextMenu.touchHandlers.onTouchEnd} + onTouchMove={projectContextMenu.touchHandlers.onTouchMove} + onKeyDown={(e: React.KeyboardEvent) => { + // Ignore key events from child buttons + if ( + e.target instanceof HTMLElement && + e.target !== e.currentTarget + ) { + return; + } + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleAddWorkspace(projectPath); + } + }} + role="button" + tabIndex={0} + aria-expanded={isExpanded} + aria-controls={workspaceListId} + aria-label={`Create workspace in ${projectName}`} + data-project-path={projectPath} + > + +
+ handleOpenProjectMenu(event, projectPath) + } + > + + + {isEditingProjectDisplayName ? ( + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + onChange={(event) => { + setEditingProjectDisplayName(event.target.value); + }} + onKeyDown={(event) => { + stopKeyboardPropagation(event); + if (event.key === "Escape") { + event.preventDefault(); + skipNextProjectNameBlurCommitRef.current = true; + cancelProjectDisplayNameEditing(); + return; + } - if (isExpanded) { - for (const member of group.displayMembers) { - renderedRows.push( - renderWorkspace( - member, - sectionId, - memberMetaByWorkspaceId.get(member.id) ?? null, - getTaskGroupMemberDepth(headerDepth), - `task-group-member:${group.storageKey}:${member.id}`, - "task-group-member", - group.title - ) + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } + }} + onBlur={(event) => { + event.stopPropagation(); + if (skipNextProjectNameBlurCommitRef.current) { + skipNextProjectNameBlurCommitRef.current = false; + return; + } + void commitProjectDisplayNameEdit( + projectPath, + event.currentTarget.value + ); + }} + /> + ) : ( +
+ + {displayProjectName} + + + ({projectAgentCount}) + +
+ )} +
+ {projectPath} +
+
+ + + + + + New chat ({formatKeybind(KEYBINDS.NEW_WORKSPACE)}) + + + + + + + Project options + +
+ + {isExpanded && ( +
+ {(() => { + // Archived workspaces are excluded from workspaceMetadata so won't appear here + + const draftsForProject = + workspaceDraftsByProject[projectPath] ?? []; + const activeDraftIds = new Set( + draftsForProject.map((draft) => draft.draftId) + ); + const draftPromotionsForProject = + workspaceDraftPromotionsByProject[projectPath] ?? {}; + const activeDraftPromotions = Object.fromEntries( + Object.entries(draftPromotionsForProject).filter( + ([draftId]) => activeDraftIds.has(draftId) + ) + ); + const promotedWorkspaceIds = new Set( + Object.values(activeDraftPromotions).map( + (metadata) => metadata.id + ) + ); + const workspacesForNormalRendering = projectWorkspaces.filter( + (workspace) => !promotedWorkspaceIds.has(workspace.id) + ); + const sections: SectionConfig[] = getSubProjectsForParent( + projectPath, + userProjects + ).map(([subProjectPath, subProjectConfig]) => ({ + id: subProjectPath, + name: getProjectDisplayName(subProjectPath, subProjectConfig), + color: subProjectConfig.color, + })); + const depthByWorkspaceId = + computeWorkspaceDepthMap(projectWorkspaces); + // Track runs that are (or were, this session) active so their + // groups stay mounted across step gaps where every member is + // momentarily terminal (no flash-out between sequential steps). + for (const key of collectActiveWorkflowGroupKeys( + workspacesForNormalRendering, + { isWorkspaceLiveActive } + )) { + sessionActiveTaskGroupKeysRef.current.add(key); + } + const visibleWorkspacesForNormalRendering = + ensureWorkflowGroupMembersVisible({ + allRows: workspacesForNormalRendering, + visibleRows: filterVisibleAgentRows( + workspacesForNormalRendering, + expandedCompletedParentIds + ), + sessionActiveGroupKeys: + sessionActiveTaskGroupKeysRef.current, + }); + const baseRowMetaByWorkspaceId = computeAgentRowRenderMeta( + workspacesForNormalRendering, + depthByWorkspaceId, + expandedCompletedParentIds + ); + const sortedDrafts = draftsForProject + .slice() + .sort((a, b) => b.createdAt - a.createdAt); + const draftVisibilityForProject = + draftVisibilityByProject[projectPath] ?? {}; + const hasVisibleDrafts = sortedDrafts.some((draft) => { + const reactiveVisibility = + draftVisibilityForProject[draft.draftId]; + return ( + reactiveVisibility ?? + isDraftVisible(projectPath, draft.draftId) ); + }); + const projectHasNoAgentsOrDrafts = + projectWorkspaces.length === 0 && !hasVisibleDrafts; + const draftNumberById = new Map( + sortedDrafts.map( + (draft, index) => [draft.draftId, index + 1] as const + ) + ); + const getDraftSectionId = ( + draft: (typeof sortedDrafts)[number] + ): string | null => + typeof draft.subProjectPath === "string" && + userProjects.get(draft.subProjectPath)?.parentProjectPath === + projectPath + ? draft.subProjectPath + : null; + + // Drafts can reference a section that has since been deleted. + // Treat those as unsectioned so they remain accessible. + const unsectionedDrafts: typeof sortedDrafts = []; + const draftsBySectionId = new Map< + string, + typeof sortedDrafts + >(); + for (const draft of sortedDrafts) { + const sectionId = getDraftSectionId(draft); + if (sectionId === null) { + unsectionedDrafts.push(draft); + continue; + } + + const existing = draftsBySectionId.get(sectionId); + if (existing) { + existing.push(draft); + } else { + draftsBySectionId.set(sectionId, [draft]); + } } - } - } - return renderedRows; - }; - - const renderDraft = ( - draft: (typeof sortedDrafts)[number] - ): React.ReactNode => { - const sectionId = getDraftSectionId(draft); - const promotedMetadata = activeDraftPromotions[draft.draftId]; - - if (promotedMetadata) { - const liveMetadata = - projectWorkspaces.find( - (workspace) => workspace.id === promotedMetadata.id - ) ?? promotedMetadata; - return renderWorkspace(liveMetadata, sectionId ?? undefined); - } + const renderWorkspace = ( + metadata: FrontendWorkspaceMetadata, + sectionId?: string, + rowRenderMetaOverride?: AgentRowRenderMeta | null, + depthOverride?: number, + keyOverride?: string, + subAgentConnectorLayout?: "default" | "task-group-member", + taskGroupHeaderTitle?: string + ) => { + const rowRenderMeta = + rowRenderMetaOverride === undefined + ? baseRowMetaByWorkspaceId.get(metadata.id) + : (rowRenderMetaOverride ?? undefined); + + return ( + + ); + }; - const draftNumber = draftNumberById.get(draft.draftId) ?? 0; - const isSelected = - pendingNewWorkspaceProject === projectPath && - pendingNewWorkspaceDraftId === draft.draftId; + const renderWorkspaceRowsWithTaskGroupCoalescing = ({ + rows, + sectionId, + rowMetaByWorkspaceId, + taskGroups, + memberMetaByWorkspaceId, + }: { + rows: FrontendWorkspaceMetadata[]; + sectionId?: string; + rowMetaByWorkspaceId: ReadonlyMap; + taskGroups: SidebarTaskGroupsResult; + memberMetaByWorkspaceId: ReadonlyMap< + string, + AgentRowRenderMeta + >; + }): React.ReactNode[] => { + const renderedRows: React.ReactNode[] = []; + + for (const workspace of rows) { + const groupKey = + taskGroups.memberGroupStorageKeyByWorkspaceId.get( + workspace.id + ); + const group = + groupKey != null + ? taskGroups.groupsByStorageKey.get(groupKey) + : undefined; + if (group == null) { + renderedRows.push( + renderWorkspace( + workspace, + sectionId, + rowMetaByWorkspaceId.get(workspace.id) + ) + ); + continue; + } - return ( - { - handleDraftVisibilityChange( - projectPath, - draft.draftId, - isVisible - ); - }} - onOpen={() => - handleOpenWorkspaceDraft(projectPath, draft.draftId) - } - onDelete={() => { - if (isSelected) { - const currentIndex = sortedDrafts.findIndex( - (d) => d.draftId === draft.draftId + if (group.anchorId !== workspace.id) { + // Non-anchor members render under the group header at the + // anchor's position (D5), so suppress them here. + continue; + } + + const headerMeta = rowMetaByWorkspaceId.get( + group.storageKey + ); + const headerDepth = + headerMeta?.depth ?? + depthByWorkspaceId[workspace.id] ?? + 0; + + // D6: groups seen active this session keep defaulting to + // expanded - no live auto-collapse on completion. An explicit + // (persisted) user toggle always wins. + if (group.kind === "workflow" && group.hasActiveMember) { + sessionActiveTaskGroupKeysRef.current.add( + group.storageKey + ); + } + const defaultExpanded = + group.kind === "workflow" && + (group.hasActiveMember || + sessionActiveTaskGroupKeysRef.current.has( + group.storageKey + )); + const isExpanded = + expandedTaskGroups[group.storageKey] ?? defaultExpanded; + const isGroupSelected = group.allMembers.some( + (member) => member.id === selectedWorkspace?.workspaceId ); - const fallback = - currentIndex >= 0 - ? (sortedDrafts[currentIndex + 1] ?? - sortedDrafts[currentIndex - 1]) - : undefined; - if (fallback) { - openWorkspaceDraft(projectPath, fallback.draftId); - } else { - navigateToProject(sectionId ?? projectPath); + const headerRow = ( + { + toggleTaskGroupExpansion( + group.storageKey, + isExpanded + ); + }} + onArchiveAll={ + group.kind === "variants" + ? (buttonElement) => + handleArchiveVariantGroup( + group.title, + group.allMembers, + buttonElement + ) + : undefined + } + /> + ); + + // Wrap the header in the same connector rail used by agent + // rows so trunks continue through the group header. + renderedRows.push( + headerMeta != null ? ( + ({ + left: getAncestorRailX(trunk.depth, "default"), + active: trunk.active, + }) + )} + connectorRailX={getSubAgentParentRailX( + headerDepth, + "default" + )} + childStatusCenterX={getSubAgentChildStatusCenterX( + headerDepth + )} + isSelected={isGroupSelected} + isElbowActive={group.runningCount > 0} + > + {headerRow} + + ) : ( + + {headerRow} + + ) + ); + + if (isExpanded) { + for (const member of group.displayMembers) { + renderedRows.push( + renderWorkspace( + member, + sectionId, + memberMetaByWorkspaceId.get(member.id) ?? null, + getTaskGroupMemberDepth(headerDepth), + `task-group-member:${group.storageKey}:${member.id}`, + "task-group-member", + group.title + ) + ); + } } } - deleteWorkspaceDraft(projectPath, draft.draftId); - }} - /> - ); - }; - - // Render age tiers for a list of workspaces - const renderAgeTiers = ( - workspaces: FrontendWorkspaceMetadata[], - tierKeyPrefix: string, - sectionId?: string, - allRowsForTaskGroupCoalescing: FrontendWorkspaceMetadata[] = workspaces - ): React.ReactNode => { - // With age grouping disabled, keep every workspace in the - // recent path (flat recency-sorted list); full-length empty - // buckets preserve tier-index assumptions below. - const { recent: topVisibleRows, buckets } = ageGroupingEnabled - ? partitionWorkspacesByAge(workspaces, workspaceRecency) - : { - recent: workspaces, - buckets: AGE_THRESHOLDS_DAYS.map( - (): FrontendWorkspaceMetadata[] => [] - ), + return renderedRows; }; - const expandedTierVisibleIds = new Set(); - const markExpandedTierRowsVisible = (tierIndex: number): void => { - const bucket = buckets[tierIndex]; - const remainingCount = buckets - .slice(tierIndex) - .reduce((sum, bucketRows) => sum + bucketRows.length, 0); - if (remainingCount === 0) { - return; - } + const renderDraft = ( + draft: (typeof sortedDrafts)[number] + ): React.ReactNode => { + const sectionId = getDraftSectionId(draft); + const promotedMetadata = activeDraftPromotions[draft.draftId]; + + if (promotedMetadata) { + const liveMetadata = + projectWorkspaces.find( + (workspace) => workspace.id === promotedMetadata.id + ) ?? promotedMetadata; + return renderWorkspace( + liveMetadata, + sectionId ?? undefined + ); + } - const tierKey = `${tierKeyPrefix}:${tierIndex}`; - const isTierExpanded = expandedOldWorkspaces[tierKey] ?? false; - if (!isTierExpanded) { - return; - } + const draftNumber = draftNumberById.get(draft.draftId) ?? 0; + const isSelected = + pendingNewWorkspaceProject === projectPath && + pendingNewWorkspaceDraftId === draft.draftId; + + return ( + { + handleDraftVisibilityChange( + projectPath, + draft.draftId, + isVisible + ); + }} + onOpen={() => + handleOpenWorkspaceDraft(projectPath, draft.draftId) + } + onDelete={() => { + if (isSelected) { + const currentIndex = sortedDrafts.findIndex( + (d) => d.draftId === draft.draftId + ); + const fallback = + currentIndex >= 0 + ? (sortedDrafts[currentIndex + 1] ?? + sortedDrafts[currentIndex - 1]) + : undefined; + + if (fallback) { + openWorkspaceDraft(projectPath, fallback.draftId); + } else { + navigateToProject(sectionId ?? projectPath); + } + } - for (const workspace of bucket) { - expandedTierVisibleIds.add(workspace.id); - } + deleteWorkspaceDraft(projectPath, draft.draftId); + }} + /> + ); + }; - const nextTier = findNextNonEmptyTier(buckets, tierIndex + 1); - if (nextTier !== -1) { - markExpandedTierRowsVisible(nextTier); - } - }; + // Render age tiers for a list of workspaces + const renderAgeTiers = ( + workspaces: FrontendWorkspaceMetadata[], + tierKeyPrefix: string, + sectionId?: string, + allRowsForTaskGroupCoalescing: FrontendWorkspaceMetadata[] = workspaces + ): React.ReactNode => { + // With age grouping disabled, keep every workspace in the + // recent path (flat recency-sorted list); full-length empty + // buckets preserve tier-index assumptions below. + const { recent: topVisibleRows, buckets } = ageGroupingEnabled + ? partitionWorkspacesByAge(workspaces, workspaceRecency) + : { + recent: workspaces, + buckets: AGE_THRESHOLDS_DAYS.map( + (): FrontendWorkspaceMetadata[] => [] + ), + }; + + const expandedTierVisibleIds = new Set(); + const markExpandedTierRowsVisible = ( + tierIndex: number + ): void => { + const bucket = buckets[tierIndex]; + const remainingCount = buckets + .slice(tierIndex) + .reduce((sum, bucketRows) => sum + bucketRows.length, 0); + if (remainingCount === 0) { + return; + } - const firstTier = findNextNonEmptyTier(buckets, 0); - if (firstTier !== -1) { - markExpandedTierRowsVisible(firstTier); - } + const tierKey = `${tierKeyPrefix}:${tierIndex}`; + const isTierExpanded = + expandedOldWorkspaces[tierKey] ?? false; + if (!isTierExpanded) { + return; + } - // Connector geometry should match the rows users can currently see, - // not hidden siblings parked behind collapsed age tiers. - const visibleRowIds = new Set([ - ...topVisibleRows.map((workspace) => workspace.id), - ...expandedTierVisibleIds, - ]); - const visibleRows = workspaces.filter((workspace) => - visibleRowIds.has(workspace.id) - ); - // Coalesce grouped task rows (variants/best-of + workflow runs) - // before deriving connector geometry: headers join the row model - // as synthetic nodes so trunks/elbows stay continuous (D5). - const taskGroups = computeSidebarTaskGroups({ - rows: visibleRows, - allRows: allRowsForTaskGroupCoalescing, - selectedWorkspaceId: selectedWorkspace?.workspaceId, - isWorkspaceLiveActive, - }); - - const rowNodes: SidebarVisibleRowNode[] = []; - const seenGroupKeys = new Set(); - for (const workspace of visibleRows) { - const groupKey = - taskGroups.memberGroupStorageKeyByWorkspaceId.get(workspace.id); - const group = - groupKey != null - ? taskGroups.groupsByStorageKey.get(groupKey) - : undefined; - if (group != null) { - if (seenGroupKeys.has(group.storageKey)) { - continue; - } - seenGroupKeys.add(group.storageKey); - const headerDepth = - baseRowMetaByWorkspaceId.get(workspace.id)?.depth ?? - depthByWorkspaceId[workspace.id] ?? - 0; - rowNodes.push({ - id: group.storageKey, - parentId: group.parentWorkspaceId, - depth: headerDepth, - isRunning: group.runningCount > 0, - baseMeta: { - depth: headerDepth, - rowKind: "subagent", - connectorPosition: "single", - connectorStartsAtParent: false, - sharedTrunkActiveThroughRow: false, - sharedTrunkActiveBelowRow: false, - ancestorTrunks: [], - hasHiddenCompletedChildren: false, - visibleCompletedChildrenCount: 0, - }, - }); - continue; - } + for (const workspace of bucket) { + expandedTierVisibleIds.add(workspace.id); + } - const baseRowMeta = baseRowMetaByWorkspaceId.get(workspace.id); - if (!baseRowMeta) { - continue; - } - rowNodes.push({ - id: workspace.id, - parentId: workspace.parentWorkspaceId, - depth: baseRowMeta.depth, - isRunning: isRunningOrStartingTaskStatus(workspace.taskStatus), - baseMeta: baseRowMeta, - }); - } - const rowMetaByVisibleWorkspaceId = - computeRowMetaForVisibleNodes(rowNodes); - - // Expanded members hang off their header row, so their connector - // meta derives from the header's computed geometry. - const memberMetaByWorkspaceId = new Map< - string, - AgentRowRenderMeta - >(); - for (const group of taskGroups.groupsByStorageKey.values()) { - const headerMeta = rowMetaByVisibleWorkspaceId.get( - group.storageKey - ); - if (headerMeta == null) { - continue; - } - for (const [ - memberId, - memberMeta, - ] of computeTaskGroupMemberRowMeta({ - group, - headerMeta, - headerDepth: headerMeta.depth, - })) { - memberMetaByWorkspaceId.set(memberId, memberMeta); - } - } + const nextTier = findNextNonEmptyTier( + buckets, + tierIndex + 1 + ); + if (nextTier !== -1) { + markExpandedTierRowsVisible(nextTier); + } + }; - const renderTier = (tierIndex: number): React.ReactNode => { - const bucket = buckets[tierIndex]; - const remainingCount = buckets - .slice(tierIndex) - .reduce((sum, b) => sum + b.length, 0); - - if (remainingCount === 0) return null; - - const tierKey = `${tierKeyPrefix}:${tierIndex}`; - const isTierExpanded = expandedOldWorkspaces[tierKey] ?? false; - const thresholdDays = AGE_THRESHOLDS_DAYS[tierIndex]; - const thresholdLabel = formatDaysThreshold(thresholdDays); - const displayCount = isTierExpanded - ? bucket.length - : remainingCount; - - return ( - - - {isTierExpanded && ( + + const baseRowMeta = baseRowMetaByWorkspaceId.get( + workspace.id + ); + if (!baseRowMeta) { + continue; + } + rowNodes.push({ + id: workspace.id, + parentId: workspace.parentWorkspaceId, + depth: baseRowMeta.depth, + isRunning: isRunningOrStartingTaskStatus( + workspace.taskStatus + ), + baseMeta: baseRowMeta, + }); + } + const rowMetaByVisibleWorkspaceId = + computeRowMetaForVisibleNodes(rowNodes); + + // Expanded members hang off their header row, so their connector + // meta derives from the header's computed geometry. + const memberMetaByWorkspaceId = new Map< + string, + AgentRowRenderMeta + >(); + for (const group of taskGroups.groupsByStorageKey.values()) { + const headerMeta = rowMetaByVisibleWorkspaceId.get( + group.storageKey + ); + if (headerMeta == null) { + continue; + } + for (const [ + memberId, + memberMeta, + ] of computeTaskGroupMemberRowMeta({ + group, + headerMeta, + headerDepth: headerMeta.depth, + })) { + memberMetaByWorkspaceId.set(memberId, memberMeta); + } + } + + const renderTier = (tierIndex: number): React.ReactNode => { + const bucket = buckets[tierIndex]; + const remainingCount = buckets + .slice(tierIndex) + .reduce((sum, b) => sum + b.length, 0); + + if (remainingCount === 0) return null; + + const tierKey = `${tierKeyPrefix}:${tierIndex}`; + const isTierExpanded = + expandedOldWorkspaces[tierKey] ?? false; + const thresholdDays = AGE_THRESHOLDS_DAYS[tierIndex]; + const thresholdLabel = formatDaysThreshold(thresholdDays); + const displayCount = isTierExpanded + ? bucket.length + : remainingCount; + + return ( + + + {isTierExpanded && ( + <> + {renderWorkspaceRowsWithTaskGroupCoalescing({ + rows: bucket, + sectionId, + rowMetaByWorkspaceId: rowMetaByVisibleWorkspaceId, + taskGroups, + memberMetaByWorkspaceId, + })} + {(() => { + const nextTier = findNextNonEmptyTier( + buckets, + tierIndex + 1 + ); + return nextTier !== -1 + ? renderTier(nextTier) + : null; + })()} + + )} + + ); + }; + + return ( <> {renderWorkspaceRowsWithTaskGroupCoalescing({ - rows: bucket, + rows: topVisibleRows, sectionId, rowMetaByWorkspaceId: rowMetaByVisibleWorkspaceId, taskGroups, memberMetaByWorkspaceId, })} - {(() => { - const nextTier = findNextNonEmptyTier( - buckets, - tierIndex + 1 - ); - return nextTier !== -1 ? renderTier(nextTier) : null; - })()} + {firstTier !== -1 && renderTier(firstTier)} - )} - - ); - }; + ); + }; - return ( - <> - {renderWorkspaceRowsWithTaskGroupCoalescing({ - rows: topVisibleRows, - sectionId, - rowMetaByWorkspaceId: rowMetaByVisibleWorkspaceId, - taskGroups, - memberMetaByWorkspaceId, - })} - {firstTier !== -1 && renderTier(firstTier)} - - ); - }; - - // Partition both the full section membership and the filtered visible rows. - // Best-of grouping stays leaf-only by consulting the unfiltered section data, - // while actual rendering still follows the visible hierarchy. - const { - unsectioned: allUnsectionedForNormalRendering, - bySectionId: allBySectionIdForNormalRendering, - } = partitionWorkspacesBySection( - workspacesForNormalRendering, - sections - ); - const { unsectioned, bySectionId } = partitionWorkspacesBySection( - visibleWorkspacesForNormalRendering, - sections - ); - - // Handle workspace drop into section - const handleWorkspaceSectionDrop = ( - workspaceId: string, - targetSectionId: string | null - ) => { - void (async () => { - const result = await assignWorkspaceToSubProject( - projectPath, - workspaceId, - targetSectionId - ); - if (result.success) { - // Refresh workspace metadata so UI shows updated sectionId - await refreshWorkspaceMetadata(); - } - })(); - }; - - // Render section with its workspaces - const renderSection = (section: SectionConfig) => { - const sectionWorkspaces = bySectionId.get(section.id) ?? []; - const sectionAllWorkspaces = - allBySectionIdForNormalRendering.get(section.id) ?? []; - const sectionDrafts = draftsBySectionId.get(section.id) ?? []; - const sectionHasPromotedAttention = sectionDrafts.some((draft) => { - const promotedMetadata = activeDraftPromotions[draft.draftId]; - return promotedMetadata - ? workspaceAttentionById.get(promotedMetadata.id) === true - : false; - }); - const sectionHasAttention = - sectionAllWorkspaces.some( - (workspace) => workspaceAttentionById.get(workspace.id) === true - ) || sectionHasPromotedAttention; - - const sectionExpandedKey = getSectionExpandedKey( - projectPath, - section.id - ); - const isSectionExpanded = - expandedSections[sectionExpandedKey] ?? true; - const shouldAutoEditSection = - autoEditingSection?.projectPath === projectPath && - autoEditingSection?.sectionId === section.id; + // Partition both the full section membership and the filtered visible rows. + // Best-of grouping stays leaf-only by consulting the unfiltered section data, + // while actual rendering still follows the visible hierarchy. + const { + unsectioned: allUnsectionedForNormalRendering, + bySectionId: allBySectionIdForNormalRendering, + } = partitionWorkspacesBySection( + workspacesForNormalRendering, + sections + ); + const { unsectioned, bySectionId } = + partitionWorkspacesBySection( + visibleWorkspacesForNormalRendering, + sections + ); - return ( - - toggleSection(projectPath, section.id)} - onAddWorkspace={() => { - // Create workspace in this section - handleAddWorkspace(projectPath, section.id); - }} - onRename={(name) => { - if (shouldAutoEditSection) { - setAutoEditingSection(null); + // Handle workspace drop into section + const handleWorkspaceSectionDrop = ( + workspaceId: string, + targetSectionId: string | null + ) => { + void (async () => { + const result = await assignWorkspaceToSubProject( + projectPath, + workspaceId, + targetSectionId + ); + if (result.success) { + // Refresh workspace metadata so UI shows updated sectionId + await refreshWorkspaceMetadata(); } - void updateDisplayName(section.id, name); - }} - onChangeColor={(color) => { - void updateProjectColor(section.id, color); - }} - autoStartEditing={shouldAutoEditSection} - onAutoCreateAbandon={ - shouldAutoEditSection - ? () => { - void (async () => { + })(); + }; + + // Render section with its workspaces + const renderSection = (section: SectionConfig) => { + const sectionWorkspaces = bySectionId.get(section.id) ?? []; + const sectionAllWorkspaces = + allBySectionIdForNormalRendering.get(section.id) ?? []; + const sectionDrafts = draftsBySectionId.get(section.id) ?? []; + const sectionHasPromotedAttention = sectionDrafts.some( + (draft) => { + const promotedMetadata = + activeDraftPromotions[draft.draftId]; + return promotedMetadata + ? workspaceAttentionById.get(promotedMetadata.id) === + true + : false; + } + ); + const sectionHasAttention = + sectionAllWorkspaces.some( + (workspace) => + workspaceAttentionById.get(workspace.id) === true + ) || sectionHasPromotedAttention; + + const sectionExpandedKey = getSectionExpandedKey( + projectPath, + section.id + ); + const isSectionExpanded = + expandedSections[sectionExpandedKey] ?? true; + const shouldAutoEditSection = + autoEditingSection?.projectPath === projectPath && + autoEditingSection?.sectionId === section.id; + + return ( + + + toggleSection(projectPath, section.id) + } + onAddWorkspace={() => { + // Create workspace in this section + handleAddWorkspace(projectPath, section.id); + }} + onRename={(name) => { + if (shouldAutoEditSection) { setAutoEditingSection(null); - await handleRemoveSection(projectPath, section.id); - })(); + } + void updateDisplayName(section.id, name); + }} + onChangeColor={(color) => { + void updateProjectColor(section.id, color); + }} + autoStartEditing={shouldAutoEditSection} + onAutoCreateAbandon={ + shouldAutoEditSection + ? () => { + void (async () => { + setAutoEditingSection(null); + await handleRemoveSection( + projectPath, + section.id + ); + })(); + } + : undefined } - : undefined - } - onAutoCreateRenameCancel={ - shouldAutoEditSection - ? () => { - setAutoEditingSection(null); + onAutoCreateRenameCancel={ + shouldAutoEditSection + ? () => { + setAutoEditingSection(null); + } + : undefined } - : undefined - } - onDelete={(anchorEl) => { - void handleRemoveSection(projectPath, section.id, anchorEl); - }} - /> - {isSectionExpanded && ( -
- {sectionDrafts.map((draft) => renderDraft(draft))} - {sectionWorkspaces.length > 0 ? ( - renderAgeTiers( - sectionWorkspaces, - getSectionTierKey(projectPath, section.id, 0).replace( - ":tier:0", - ":tier" - ), - section.id, - sectionAllWorkspaces - ) - ) : sectionDrafts.length === 0 ? ( -
- No chats in this sub-project + onDelete={(anchorEl) => { + void handleRemoveSection( + projectPath, + section.id, + anchorEl + ); + }} + /> + {isSectionExpanded && ( +
+ {sectionDrafts.map((draft) => renderDraft(draft))} + {sectionWorkspaces.length > 0 ? ( + renderAgeTiers( + sectionWorkspaces, + getSectionTierKey( + projectPath, + section.id, + 0 + ).replace(":tier:0", ":tier"), + section.id, + sectionAllWorkspaces + ) + ) : sectionDrafts.length === 0 ? ( +
+ No chats in this sub-project +
+ ) : null} +
+ )} + + ); + }; + + return ( + <> + {projectHasNoAgentsOrDrafts && ( +
+ Empty
- ) : null} -
- )} - - ); - }; - - return ( - <> - {projectHasNoAgentsOrDrafts && ( -
- Empty -
- )} - {/* Unsectioned workspaces first - always show drop zone when sections exist */} - {sections.length > 0 ? ( - - {unsectionedDrafts.map((draft) => renderDraft(draft))} - {unsectioned.length > 0 ? ( - renderAgeTiers( - unsectioned, - getTierKey(projectPath, 0).replace(":0", ""), - undefined, - allUnsectionedForNormalRendering - ) - ) : unsectionedDrafts.length === 0 ? ( -
- No unsectioned chats -
- ) : null} -
- ) : ( - <> - {unsectionedDrafts.map((draft) => renderDraft(draft))} - {unsectioned.length > 0 && - renderAgeTiers( - unsectioned, - getTierKey(projectPath, 0).replace(":0", ""), - undefined, - allUnsectionedForNormalRendering )} - - )} - - {/* Sections */} - {sections.map(renderSection)} - - ); - })()} -
- )} -
- ); - }) - )} + {/* Unsectioned workspaces first - always show drop zone when sections exist */} + {sections.length > 0 ? ( + + {unsectionedDrafts.map((draft) => renderDraft(draft))} + {unsectioned.length > 0 ? ( + renderAgeTiers( + unsectioned, + getTierKey(projectPath, 0).replace(":0", ""), + undefined, + allUnsectionedForNormalRendering + ) + ) : unsectionedDrafts.length === 0 ? ( +
+ No unsectioned chats +
+ ) : null} +
+ ) : ( + <> + {unsectionedDrafts.map((draft) => renderDraft(draft))} + {unsectioned.length > 0 && + renderAgeTiers( + unsectioned, + getTierKey(projectPath, 0).replace(":0", ""), + undefined, + allUnsectionedForNormalRendering + )} + + )} + + {/* Sections */} + {sections.map(renderSection)} + + ); + })()} +
+ )} +
+ ); + }) + )} + + )} +
)} diff --git a/src/browser/components/ProjectSidebar/flatWorkspaceList.test.ts b/src/browser/components/ProjectSidebar/flatWorkspaceList.test.ts new file mode 100644 index 0000000000..f88a349515 --- /dev/null +++ b/src/browser/components/ProjectSidebar/flatWorkspaceList.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectConfig } from "@/common/types/project"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; +import { buildFlatWorkspaceList } from "./flatWorkspaceList"; + +function workspace( + id: string, + projectPath: string, + overrides: Partial = {} +): FrontendWorkspaceMetadata { + return { + id, + name: id, + projectName: projectPath.split("/").at(-1) ?? projectPath, + projectPath, + namedWorkspacePath: `${projectPath}/${id}`, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + ...overrides, + }; +} + +const projects = new Map([ + ["/repo/a", { workspaces: [], displayName: "Alpha" }], + ["/repo/b", { workspaces: [], displayName: "Beta" }], + ["/repo/a/sub", { workspaces: [], displayName: "Sub", parentProjectPath: "/repo/a" }], +]); + +describe("buildFlatWorkspaceList", () => { + test("sorts root threads globally by recency while keeping descendants adjacent", () => { + const parent = workspace("parent", "/repo/a"); + const child = workspace("child", "/repo/a", { parentWorkspaceId: "parent" }); + const newer = workspace("newer", "/repo/b"); + + const rows = buildFlatWorkspaceList({ + sortedWorkspacesByProject: new Map([ + ["/repo/a", [parent, child]], + ["/repo/b", [newer]], + ]), + workspaceRecency: { parent: 100, child: 500, newer: 200 }, + userProjects: projects, + githubRepoInfoByProject: {}, + multiProjectWorkspacesEnabled: true, + }); + + expect(rows.map((row) => row.metadata.id)).toEqual(["newer", "parent", "child"]); + }); + + test("uses canonical name and timestamp tie-breakers for roots", () => { + const zeta = workspace("a-id", "/repo/a", { name: "zeta", createdAt: "invalid" }); + const alpha = workspace("z-id", "/repo/b", { name: "alpha", createdAt: "invalid" }); + + const rows = buildFlatWorkspaceList({ + sortedWorkspacesByProject: new Map([ + ["/repo/a", [zeta]], + ["/repo/b", [alpha]], + ]), + workspaceRecency: {}, + userProjects: projects, + githubRepoInfoByProject: {}, + multiProjectWorkspacesEnabled: true, + }); + + expect(rows.map((row) => row.metadata.id)).toEqual(["z-id", "a-id"]); + }); + + test("includes scratch once and assigns multi-project rows to their primary project", () => { + const scratch = workspace("scratch", "/scratch", { kind: "scratch" }); + const multi = workspace("multi", "/repo/a", { + projects: [ + { projectPath: "/repo/b", projectName: "beta-primary" }, + { projectPath: "/repo/a", projectName: "alpha-secondary" }, + ], + }); + + const rows = buildFlatWorkspaceList({ + sortedWorkspacesByProject: new Map([ + ["/repo/a", [scratch, multi]], + ["/repo/b", [scratch, multi]], + ]), + workspaceRecency: { scratch: 200, multi: 100 }, + userProjects: projects, + githubRepoInfoByProject: {}, + multiProjectWorkspacesEnabled: true, + }); + + expect(rows.map((row) => [row.metadata.id, row.projectPath, row.projectName])).toEqual([ + ["scratch", null, "Scratch"], + ["multi", "/repo/b", "beta-primary"], + ]); + }); + + test("drops orphaned and cyclic descendants like the canonical workspace tree", () => { + const root = workspace("root", "/repo/a"); + const orphan = workspace("orphan", "/repo/a", { parentWorkspaceId: "missing" }); + const cycleA = workspace("cycle-a", "/repo/a", { parentWorkspaceId: "cycle-b" }); + const cycleB = workspace("cycle-b", "/repo/a", { parentWorkspaceId: "cycle-a" }); + + const rows = buildFlatWorkspaceList({ + sortedWorkspacesByProject: new Map([["/repo/a", [root, orphan, cycleA, cycleB]]]), + workspaceRecency: {}, + userProjects: projects, + githubRepoInfoByProject: {}, + multiProjectWorkspacesEnabled: true, + }); + + expect(rows.map((row) => row.metadata.id)).toEqual(["root"]); + }); + + test("inherits the configured sub-project from the parent chain and ignores stale sections", () => { + const subInfo = { + owner: "coder", + repo: "sub", + avatarUrl: "https://github.com/coder.png?size=64", + }; + const parent = workspace("parent", "/repo/a", { subProjectPath: "/repo/a/sub" }); + const child = workspace("child", "/repo/a", { parentWorkspaceId: "parent" }); + const stale = workspace("stale", "/repo/a", { subProjectPath: "/repo/a/deleted" }); + + const rows = buildFlatWorkspaceList({ + sortedWorkspacesByProject: new Map([["/repo/a", [parent, child, stale]]]), + workspaceRecency: { parent: 300, stale: 100 }, + userProjects: projects, + githubRepoInfoByProject: { "/repo/a/sub": subInfo }, + multiProjectWorkspacesEnabled: true, + }); + + expect( + rows.map((row) => [row.metadata.id, row.projectPath, row.projectName, row.githubRepoInfo]) + ).toEqual([ + ["parent", "/repo/a/sub", "Sub", subInfo], + ["child", "/repo/a/sub", "Sub", subInfo], + ["stale", "/repo/a", "Alpha", null], + ]); + }); + + test("attaches GitHub identity by the resolved project path", () => { + const info = { + owner: "coder", + repo: "mux", + avatarUrl: "https://github.com/coder.png?size=64", + }; + const rows = buildFlatWorkspaceList({ + sortedWorkspacesByProject: new Map([["/repo/a", [workspace("a", "/repo/a")]]]), + workspaceRecency: { a: 1 }, + userProjects: projects, + githubRepoInfoByProject: { "/repo/a": info }, + multiProjectWorkspacesEnabled: true, + }); + + expect(rows[0]).toMatchObject({ projectName: "Alpha", githubRepoInfo: info }); + }); +}); diff --git a/src/browser/components/ProjectSidebar/flatWorkspaceList.ts b/src/browser/components/ProjectSidebar/flatWorkspaceList.ts new file mode 100644 index 0000000000..b180ec49e5 --- /dev/null +++ b/src/browser/components/ProjectSidebar/flatWorkspaceList.ts @@ -0,0 +1,104 @@ +import type { GitHubRepoInfo } from "@/common/orpc/schemas/githubRepoInfo"; +import type { ProjectConfig } from "@/common/types/project"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import { isMultiProject } from "@/common/utils/multiProject"; +import { getProjectDisplayName } from "@/common/utils/subProjects"; +import { + compareWorkspacesByRecency, + flattenWorkspaceTree, + resolveEffectiveSectionId, +} from "@/browser/utils/ui/workspaceFiltering"; + +export interface FlatWorkspaceRow { + metadata: FrontendWorkspaceMetadata; + projectPath: string | null; + projectName: string; + githubRepoInfo: GitHubRepoInfo | null; +} + +interface BuildFlatWorkspaceListParams { + sortedWorkspacesByProject: Map; + workspaceRecency: Record; + userProjects: Map; + githubRepoInfoByProject: Record; + multiProjectWorkspacesEnabled: boolean; +} + +const EMPTY_SECTION_IDS: ReadonlySet = new Set(); + +function buildSectionIdsByParent( + userProjects: Map +): Map> { + const byParent = new Map>(); + for (const [projectPath, config] of userProjects) { + if (!config.parentProjectPath) { + continue; + } + const sectionIds = byParent.get(config.parentProjectPath) ?? new Set(); + sectionIds.add(projectPath); + byParent.set(config.parentProjectPath, sectionIds); + } + return byParent; +} + +function resolveProject( + workspace: FrontendWorkspaceMetadata, + userProjects: Map, + byId: ReadonlyMap, + sectionIdsByParent: ReadonlyMap> +): { projectPath: string | null; projectName: string } { + if (workspace.kind === "scratch") { + return { projectPath: null, projectName: "Scratch" }; + } + if (isMultiProject(workspace)) { + const primary = workspace.projects?.[0]; + return { + projectPath: primary?.projectPath ?? workspace.projectPath, + projectName: primary?.projectName ?? workspace.projectName, + }; + } + + // Match the section renderer: honor subProjectPath only when it is a + // configured sub-project and inherit it from the parent chain otherwise. + const sectionIds = sectionIdsByParent.get(workspace.projectPath) ?? EMPTY_SECTION_IDS; + const projectPath = + resolveEffectiveSectionId(workspace, byId, sectionIds) ?? workspace.projectPath; + return { + projectPath, + projectName: getProjectDisplayName(projectPath, userProjects.get(projectPath)), + }; +} + +export function buildFlatWorkspaceList(params: BuildFlatWorkspaceListParams): FlatWorkspaceRow[] { + const allRows: FrontendWorkspaceMetadata[] = []; + const byId = new Map(); + for (const workspaces of params.sortedWorkspacesByProject.values()) { + for (const workspace of workspaces) { + if (byId.has(workspace.id)) { + continue; + } + if (isMultiProject(workspace) && !params.multiProjectWorkspacesEnabled) { + continue; + } + byId.set(workspace.id, workspace); + allRows.push(workspace); + } + } + + const ordered = flattenWorkspaceTree(allRows, (left, right) => + compareWorkspacesByRecency(left, right, params.workspaceRecency) + ); + + const sectionIdsByParent = buildSectionIdsByParent(params.userProjects); + return ordered.map((metadata) => { + const project = resolveProject(metadata, params.userProjects, byId, sectionIdsByParent); + return { + metadata, + ...project, + githubRepoInfo: + project.projectPath == null + ? null + : (params.githubRepoInfoByProject[project.projectPath] ?? null), + }; + }); +} diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index e0fa9a38cb..daaae158ab 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -4,7 +4,11 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { ThemeProvider } from "@/browser/contexts/ThemeContext"; import * as ActualSelectPrimitiveModule from "@/browser/components/SelectPrimitive/SelectPrimitive"; import { installDom } from "../../../../../tests/ui/dom"; -import { BASH_COLLAPSED_SUMMARY_MODE_KEY } from "@/common/constants/storage"; +import { + BASH_COLLAPSED_SUMMARY_MODE_KEY, + SIDEBAR_DISPLAY_STYLE_KEY, +} from "@/common/constants/storage"; +import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { DEFAULT_CODER_ARCHIVE_BEHAVIOR, type CoderWorkspaceArchiveBehavior, @@ -335,6 +339,15 @@ describe("GeneralSection", () => { ); }); + test("selects flat sidebar cards and persists the preference", async () => { + const { view } = renderGeneralSection(); + + expect(getSelectTrigger(view, "Sidebar layout").textContent).toContain("Project sections"); + await chooseSelectOption(view, "Sidebar layout", "Flat cards"); + + expect(readPersistedState(SIDEBAR_DISPLAY_STYLE_KEY, "projects")).toBe("flat"); + }); + test("loads and persists the full-width chat transcript toggle", async () => { const { updateChatTranscriptFullWidthMock, view } = renderGeneralSection({ chatTranscriptFullWidth: true, diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index e05a114285..f773980621 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -24,9 +24,12 @@ import { CHAT_TRANSCRIPT_FULL_WIDTH_KEY, DEFAULT_BASH_COLLAPSED_SUMMARY_MODE, SIDEBAR_AGE_GROUPING_KEY, + SIDEBAR_DISPLAY_STYLE_KEY, + DEFAULT_SIDEBAR_DISPLAY_STYLE, TRANSCRIPT_DENSITIES, normalizeBashCollapsedSummaryMode, normalizeEditorConfig, + normalizeSidebarDisplayStyle, normalizeTerminalFontConfig, normalizeTranscriptDensity, type BashCollapsedSummaryMode, @@ -34,6 +37,7 @@ import { type EditorConfig, type EditorType, type LaunchBehavior, + type SidebarDisplayStyle, type TerminalFontConfig, } from "@/common/constants/storage"; import { @@ -125,6 +129,10 @@ const TRANSCRIPT_DENSITY_OPTIONS = TRANSCRIPT_DENSITIES.map((value) => ({ value, label: TRANSCRIPT_DENSITY_LABELS[value], })); +const SIDEBAR_DISPLAY_STYLE_OPTIONS: Array<{ value: SidebarDisplayStyle; label: string }> = [ + { value: "projects", label: "Project sections" }, + { value: "flat", label: "Flat cards" }, +]; const ARCHIVE_BEHAVIOR_OPTIONS = [ { value: "keep", label: "Keep running" }, { value: "stop", label: "Stop workspace" }, @@ -158,6 +166,12 @@ export function GeneralSection() { SIDEBAR_AGE_GROUPING_KEY, true ); + const [rawSidebarDisplayStyle, setSidebarDisplayStyle] = usePersistedState( + SIDEBAR_DISPLAY_STYLE_KEY, + DEFAULT_SIDEBAR_DISPLAY_STYLE, + { listener: true } + ); + const sidebarDisplayStyle = normalizeSidebarDisplayStyle(rawSidebarDisplayStyle); const [transcriptDensity, setTranscriptDensity] = useTranscriptDensity(); const [rawTerminalFontConfig, setTerminalFontConfig] = usePersistedState( TERMINAL_FONT_CONFIG_KEY, @@ -544,6 +558,30 @@ export function GeneralSection() { />
+
+
+
Sidebar layout
+
+ Choose project sections or one flat card list. +
+
+ +
+
Group sidebar workspaces by age
diff --git a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx index 779bb7172a..9db0e7ca41 100644 --- a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx +++ b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx @@ -22,6 +22,7 @@ import { import { SELECTED_WORKSPACE_KEY, SIDEBAR_AGE_GROUPING_KEY, + SIDEBAR_DISPLAY_STYLE_KEY, UI_THEME_KEY, } from "@/common/constants/storage"; import type { ServerAuthSession } from "@/common/orpc/types"; @@ -52,6 +53,7 @@ export function resetStorybookPersistedStateForStory(): void { // Sidebar stories can write sidebarAgeGrouping=false into the shared // origin; clear it so the GeneralSection switch snapshots its default. localStorage.removeItem(SIDEBAR_AGE_GROUPING_KEY); + localStorage.removeItem(SIDEBAR_DISPLAY_STYLE_KEY); } } diff --git a/src/browser/stories/meta.tsx b/src/browser/stories/meta.tsx index c14b14112f..21cba31158 100644 --- a/src/browser/stories/meta.tsx +++ b/src/browser/stories/meta.tsx @@ -15,6 +15,7 @@ import { ThemeProvider } from "@/browser/contexts/ThemeContext"; import { SELECTED_WORKSPACE_KEY, SIDEBAR_AGE_GROUPING_KEY, + SIDEBAR_DISPLAY_STYLE_KEY, UI_THEME_KEY, } from "@/common/constants/storage"; @@ -88,6 +89,7 @@ function resetStorybookPersistedStateForStory(): void { // Stories that disable sidebar age grouping must not leak the setting // into later stories via the shared localStorage origin. localStorage.removeItem(SIDEBAR_AGE_GROUPING_KEY); + localStorage.removeItem(SIDEBAR_DISPLAY_STYLE_KEY); } } function getStorybookRenderKey(): string | null { diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 4e1175c7cf..5f1514d0f3 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -21,6 +21,7 @@ import type { WorkspaceActivitySnapshot, } from "@/common/types/workspace"; import type { ProjectConfig } from "@/node/config"; +import type { ConfiguredProjectGitHubRepoInfo } from "@/common/orpc/schemas/githubRepoInfo"; import { DEFAULT_LAYOUT_PRESETS_CONFIG, normalizeLayoutPresetsConfig, @@ -120,6 +121,7 @@ export interface MockORPCClientOptions { /** Layout presets config for Settings → Layouts stories */ layoutPresets?: LayoutPresetsConfig; projects?: Map; + githubRepoInfoByProject?: ConfiguredProjectGitHubRepoInfo; workspaces?: FrontendWorkspaceMetadata[]; /** Pre-seeded multi-project git status rows keyed by workspace ID. */ projectGitStatusesByWorkspace?: Map; @@ -347,6 +349,7 @@ type MockMcpTestResult = { success: true; tools: string[] } | { success: false; export function createMockORPCClient(options: MockORPCClientOptions = {}): APIClient { const { projects: providedProjects = new Map(), + githubRepoInfoByProject = {}, workspaces = [], projectGitStatusesByWorkspace = new Map(), workspaceActivitySnapshots = {}, @@ -1281,6 +1284,15 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }), pickDirectory: () => Promise.resolve(null), getDefaultProjectDir: () => Promise.resolve("~/.mux/projects"), + githubRepoInfo: () => + Promise.resolve( + Object.fromEntries( + Array.from(projects.keys()).map((projectPath) => [ + projectPath, + githubRepoInfoByProject[projectPath] ?? null, + ]) + ) + ), setDefaultProjectDir: () => Promise.resolve(), clone: () => Promise.resolve( diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 9468a89c6b..0cf2396e41 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -80,6 +80,7 @@ export const CommandIds = { // Appearance commands themeToggle: () => "appearance:theme:toggle" as const, themeSet: (theme: string) => `appearance:theme:set:${theme}` as const, + sidebarDisplayStyleSet: (style: string) => `appearance:sidebar:set:${style}` as const, // Analytics commands analyticsRebuildDatabase: () => "analytics:rebuild-database" as const, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index ed8a31043f..b3b527a272 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -4,7 +4,8 @@ import type { ProjectConfig } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { GlobalWindow } from "happy-dom"; -import { getModelKey } from "@/common/constants/storage"; +import { getModelKey, SIDEBAR_DISPLAY_STYLE_KEY } from "@/common/constants/storage"; +import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import type { WorkspaceState } from "@/browser/stores/WorkspaceStore"; import type { APIClient } from "@/browser/contexts/API"; @@ -281,6 +282,24 @@ test("appearance commands omit auto when auto preference is already selected", ( expect(themeSetCommandIds).not.toContain("appearance:theme:set:auto"); }); +test("appearance commands switch between project sections and flat cards", async () => { + await withTestWindow(async () => { + const flatAction = getActions().find((action) => action.id === "appearance:sidebar:set:flat"); + expect(flatAction).toBeDefined(); + await flatAction?.run(); + expect(readPersistedState(SIDEBAR_DISPLAY_STYLE_KEY, "projects")).toBe("flat"); + + const flatActions = getActions(); + expect(flatActions.some((action) => action.id === "appearance:sidebar:set:flat")).toBe(false); + const projectAction = flatActions.find( + (action) => action.id === "appearance:sidebar:set:projects" + ); + expect(projectAction).toBeDefined(); + await projectAction?.run(); + expect(readPersistedState(SIDEBAR_DISPLAY_STYLE_KEY, "flat")).toBe("projects"); + }); +}); + test("buildCoreSources adds thinking effort command", () => { const actions = getActions({ getThinkingLevel: () => "medium" }); const thinkingAction = actions.find((a) => a.id === "thinking:set-level"); diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index da9b89a18f..bfc2b93f4f 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -21,8 +21,14 @@ import { import assert from "@/common/utils/assert"; import { isWorkspacePinnable, isWorkspacePinned } from "@/common/utils/pin"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; -import { RIGHT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage"; -import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { + DEFAULT_SIDEBAR_DISPLAY_STYLE, + RIGHT_SIDEBAR_COLLAPSED_KEY, + SIDEBAR_DISPLAY_STYLE_KEY, + SIDEBAR_DISPLAY_STYLES, + normalizeSidebarDisplayStyle, +} from "@/common/constants/storage"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { CommandIds } from "@/browser/utils/commandIds"; import { isTabType, type TabType } from "@/browser/types/rightSidebar"; import { @@ -897,6 +903,21 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi } } + const sidebarDisplayStyle = normalizeSidebarDisplayStyle( + readPersistedState(SIDEBAR_DISPLAY_STYLE_KEY, DEFAULT_SIDEBAR_DISPLAY_STYLE) + ); + for (const displayStyle of SIDEBAR_DISPLAY_STYLES) { + if (displayStyle === sidebarDisplayStyle) { + continue; + } + list.push({ + id: CommandIds.sidebarDisplayStyleSet(displayStyle), + title: displayStyle === "flat" ? "Use Flat Sidebar Cards" : "Use Project Sidebar Sections", + section: section.appearance, + run: () => updatePersistedState(SIDEBAR_DISPLAY_STYLE_KEY, displayStyle), + }); + } + return list; }); diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index 4ffe2f47b5..a2052d66ca 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -8,8 +8,9 @@ interface WorkspaceGroupConfig { id: string; } -function flattenWorkspaceTree( - workspaces: FrontendWorkspaceMetadata[] +export function flattenWorkspaceTree( + workspaces: FrontendWorkspaceMetadata[], + compareRoots?: (left: FrontendWorkspaceMetadata, right: FrontendWorkspaceMetadata) => number ): FrontendWorkspaceMetadata[] { if (workspaces.length === 0) return []; @@ -39,6 +40,10 @@ function flattenWorkspaceTree( childrenByParent.set(parentId, children); } + if (compareRoots) { + roots.sort(compareRoots); + } + const result: FrontendWorkspaceMetadata[] = []; const visited = new Set(); const stack = roots.slice().reverse(); @@ -639,6 +644,32 @@ function comparePinnedPlacement( return null; } +export function compareWorkspacesByRecency( + left: FrontendWorkspaceMetadata, + right: FrontendWorkspaceMetadata, + workspaceRecency: Record +): number { + const pinnedPlacement = comparePinnedPlacement(left, right); + if (pinnedPlacement !== null) { + return pinnedPlacement; + } + + const leftTimestamp = workspaceRecency[left.id] ?? 0; + const rightTimestamp = workspaceRecency[right.id] ?? 0; + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp - leftTimestamp; + } + + const leftCreatedAt = parseTimestampMs(left.createdAt); + const rightCreatedAt = parseTimestampMs(right.createdAt); + if (leftCreatedAt !== rightCreatedAt) { + return rightCreatedAt - leftCreatedAt; + } + + const nameOrder = compareStringsAsc(left.name, right.name); + return nameOrder !== 0 ? nameOrder : compareStringsAsc(left.id, right.id); +} + /** * Build a map of project paths to sorted workspace metadata lists. * Includes both persisted workspaces (from config) and workspaces from @@ -683,31 +714,7 @@ export function buildSortedWorkspacesByProject( // IMPORTANT: Include deterministic tie-breakers so Storybook visual snapshots can't // flip ordering when multiple workspaces have equal recency. for (const metadataList of result.values()) { - metadataList.sort((a, b) => { - const pinnedPlacement = comparePinnedPlacement(a, b); - if (pinnedPlacement !== null) { - return pinnedPlacement; - } - - const aTimestamp = workspaceRecency[a.id] ?? 0; - const bTimestamp = workspaceRecency[b.id] ?? 0; - if (aTimestamp !== bTimestamp) { - return bTimestamp - aTimestamp; - } - - const aCreatedAt = parseTimestampMs(a.createdAt); - const bCreatedAt = parseTimestampMs(b.createdAt); - if (aCreatedAt !== bCreatedAt) { - return bCreatedAt - aCreatedAt; - } - - const nameOrder = compareStringsAsc(a.name, b.name); - if (nameOrder !== 0) { - return nameOrder; - } - - return compareStringsAsc(a.id, b.id); - }); + metadataList.sort((left, right) => compareWorkspacesByRecency(left, right, workspaceRecency)); } // Ensure child workspaces appear directly below their parents. diff --git a/src/common/constants/storage.test.ts b/src/common/constants/storage.test.ts index da3bd66c8b..763769cc6b 100644 --- a/src/common/constants/storage.test.ts +++ b/src/common/constants/storage.test.ts @@ -4,6 +4,7 @@ import { deleteWorkspaceStorage, getDraftScopeId, getInputAttachmentsKey, + normalizeSidebarDisplayStyle, normalizeTranscriptDensity, } from "@/common/constants/storage"; @@ -64,6 +65,12 @@ describe("storage workspace-scoped keys", () => { expect(getInputAttachmentsKey("ws-123")).toBe("inputAttachments:ws-123"); }); + test("normalizeSidebarDisplayStyle falls back for corrupt values", () => { + expect(normalizeSidebarDisplayStyle("flat")).toBe("flat"); + expect(normalizeSidebarDisplayStyle("cards")).toBe("projects"); + expect(normalizeSidebarDisplayStyle(null)).toBe("projects"); + }); + test("normalizeTranscriptDensity falls back for corrupt values", () => { expect(normalizeTranscriptDensity("hyper")).toBe("hyper"); expect(normalizeTranscriptDensity("compact")).toBe("normal"); diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 6f42b25cd5..ab16cb4152 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -649,6 +649,17 @@ export const LEFT_SIDEBAR_COLLAPSED_KEY = "sidebarCollapsed"; */ export const SIDEBAR_AGE_GROUPING_KEY = "sidebarAgeGrouping"; +export const SIDEBAR_DISPLAY_STYLE_KEY = "sidebarDisplayStyle"; +export const SIDEBAR_DISPLAY_STYLES = ["projects", "flat"] as const; +export type SidebarDisplayStyle = (typeof SIDEBAR_DISPLAY_STYLES)[number]; +export const DEFAULT_SIDEBAR_DISPLAY_STYLE: SidebarDisplayStyle = "projects"; + +export function normalizeSidebarDisplayStyle(value: unknown): SidebarDisplayStyle { + return SIDEBAR_DISPLAY_STYLES.includes(value as SidebarDisplayStyle) + ? (value as SidebarDisplayStyle) + : DEFAULT_SIDEBAR_DISPLAY_STYLE; +} + /** * Left sidebar width * Format: "left-sidebar:width" diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 69e60c2f74..5759300b62 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -16,6 +16,11 @@ export { // Project schemas export { ProjectConfigSchema, WorkspaceConfigSchema } from "./schemas/project"; +export { + ConfiguredProjectGitHubRepoInfoSchema, + GitHubRepoInfoSchema, +} from "./schemas/githubRepoInfo"; +export type { ConfiguredProjectGitHubRepoInfo, GitHubRepoInfo } from "./schemas/githubRepoInfo"; // Goal schemas export { diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 328b0caff6..57b2589fd9 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -33,6 +33,7 @@ import { GoalSetErrorSchema, GoalSetInputSchema, } from "./goal"; +import { ConfiguredProjectGitHubRepoInfoSchema } from "./githubRepoInfo"; import { ProjectConfigSchema } from "./project"; import { MemoryChangeEventSchema, @@ -642,6 +643,10 @@ export const projects = { input: z.void(), output: z.string(), }, + githubRepoInfo: { + input: z.void(), + output: ConfiguredProjectGitHubRepoInfoSchema, + }, setDefaultProjectDir: { input: z.object({ path: z.string() }), output: z.void(), diff --git a/src/common/orpc/schemas/githubRepoInfo.ts b/src/common/orpc/schemas/githubRepoInfo.ts new file mode 100644 index 0000000000..967eb932ff --- /dev/null +++ b/src/common/orpc/schemas/githubRepoInfo.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const GitHubRepoInfoSchema = z.object({ + owner: z.string().min(1), + repo: z.string().min(1), + avatarUrl: z.string().url(), +}); + +export const ConfiguredProjectGitHubRepoInfoSchema = z.record( + z.string(), + GitHubRepoInfoSchema.nullable() +); + +export type GitHubRepoInfo = z.infer; +export type ConfiguredProjectGitHubRepoInfo = z.infer; diff --git a/src/common/utils/githubRemote.test.ts b/src/common/utils/githubRemote.test.ts new file mode 100644 index 0000000000..f6ab77021f --- /dev/null +++ b/src/common/utils/githubRemote.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { parseGitHubRemote } from "./githubRemote"; + +describe("parseGitHubRemote", () => { + const validCases: Array<[string, { owner: string; repo: string }]> = [ + ["https://github.com/coder/mux", { owner: "coder", repo: "mux" }], + ["https://github.com/coder/mux.git", { owner: "coder", repo: "mux" }], + ["https://github.com/coder/mux.git/", { owner: "coder", repo: "mux" }], + ["https://token@github.com/coder/mux.git", { owner: "coder", repo: "mux" }], + ["https://user:token@github.com/coder/mux", { owner: "coder", repo: "mux" }], + ["ssh://git@github.com/coder/mux.git", { owner: "coder", repo: "mux" }], + ["ssh://git@github.com/coder/mux.git/", { owner: "coder", repo: "mux" }], + ["git@github.com:coder/mux.git", { owner: "coder", repo: "mux" }], + ["github.com:coder/mux", { owner: "coder", repo: "mux" }], + [" git@github.com:coder/mux.git ", { owner: "coder", repo: "mux" }], + ]; + + for (const [remote, expected] of validCases) { + test(`parses ${remote.trim()}`, () => { + expect(parseGitHubRemote(remote)).toEqual(expected); + }); + } + + const invalidCases = [ + "", + "https://gitlab.com/coder/mux.git", + "git@gitlab.com:coder/mux.git", + "http://github.com/coder/mux.git", + "https://github.com/coder", + "https://github.com/coder/mux/issues", + "https://github.com//mux.git", + "not a remote", + ]; + + for (const remote of invalidCases) { + test(`rejects ${remote || "an empty remote"}`, () => { + expect(parseGitHubRemote(remote)).toBeNull(); + }); + } +}); diff --git a/src/common/utils/githubRemote.ts b/src/common/utils/githubRemote.ts new file mode 100644 index 0000000000..57629915f6 --- /dev/null +++ b/src/common/utils/githubRemote.ts @@ -0,0 +1,46 @@ +export interface GitHubRemoteIdentity { + owner: string; + repo: string; +} + +const GITHUB_SEGMENT_PATTERN = /^[A-Za-z0-9_.-]+$/; + +function parsePath(pathname: string): GitHubRemoteIdentity | null { + const normalized = pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, ""); + const segments = normalized.split("/"); + if ( + segments.length !== 2 || + !segments[0] || + !segments[1] || + !GITHUB_SEGMENT_PATTERN.test(segments[0]) || + !GITHUB_SEGMENT_PATTERN.test(segments[1]) + ) { + return null; + } + return { owner: segments[0], repo: segments[1] }; +} + +export function parseGitHubRemote(remote: string): GitHubRemoteIdentity | null { + const trimmed = remote.trim(); + if (!trimmed) { + return null; + } + + const scpMatch = /^(?:[^@\s]+@)?github\.com:(.+)$/i.exec(trimmed); + if (scpMatch) { + return parsePath(scpMatch[1]); + } + + try { + const url = new URL(trimmed); + if (url.hostname.toLowerCase() !== "github.com") { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "ssh:") { + return null; + } + return parsePath(url.pathname); + } catch { + return null; + } +} diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index 51b1d5f721..b39253c1d7 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -867,3 +867,28 @@ describe("router config.saveConfig", () => { expect(savedTaskSettings.proposePlanImplementReplacesChatHistory).toBe(true); }); }); + +describe("router project GitHub info", () => { + test("exposes the no-argument bulk project identity lookup", async () => { + const githubRepoInfo = mock(() => + Promise.resolve({ + "/repo": { + owner: "coder", + repo: "mux", + avatarUrl: "https://github.com/coder.png?size=64", + }, + }) + ); + const context = { projectService: { githubRepoInfo } } as unknown as ORPCContext; + const client = createRouterClient(router(), { context }); + + expect(await client.projects.githubRepoInfo()).toEqual({ + "/repo": { + owner: "coder", + repo: "mux", + avatarUrl: "https://github.com/coder.png?size=64", + }, + }); + expect(githubRepoInfo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 0580774c42..c1f9158883 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -3166,6 +3166,12 @@ export const router = (authToken?: string) => { .handler(({ context }) => { return context.projectService.getDefaultProjectDir(); }), + githubRepoInfo: t + .input(schemas.projects.githubRepoInfo.input) + .output(schemas.projects.githubRepoInfo.output) + .handler(({ context }) => { + return context.projectService.githubRepoInfo(); + }), setDefaultProjectDir: t .input(schemas.projects.setDefaultProjectDir.input) .output(schemas.projects.setDefaultProjectDir.output) diff --git a/src/node/services/projectService.test.ts b/src/node/services/projectService.test.ts index 7274683bab..e3508d4b5e 100644 --- a/src/node/services/projectService.test.ts +++ b/src/node/services/projectService.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, test, expect, beforeEach, afterEach } from "bun:test"; import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; @@ -2208,3 +2208,66 @@ exit 1 }); }); }); + +describe("ProjectService GitHub repo info", () => { + test("returns GitHub identity or null for every configured project", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "projectservice-github-test-")); + try { + const config = new Config(tempDir); + await config.editConfig((current) => { + current.projects.set("/repo/github", { workspaces: [] }); + current.projects.set("/repo/local", { workspaces: [] }); + return current; + }); + const service = new ProjectService(config, undefined, (projectPath) => + Promise.resolve( + projectPath === "/repo/github" + ? "https://github.com/coder/mux.git" + : "https://gitlab.com/coder/mux.git" + ) + ); + + expect(await service.githubRepoInfo()).toEqual({ + "/repo/github": { + owner: "coder", + repo: "mux", + avatarUrl: "https://github.com/coder.png?size=64", + }, + "/repo/local": null, + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + test("coalesces concurrent reads and caches the result", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "projectservice-github-cache-test-")); + try { + const config = new Config(tempDir); + await config.editConfig((current) => { + current.projects.set("/repo/github", { workspaces: [] }); + return current; + }); + let reads = 0; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const service = new ProjectService(config, undefined, async () => { + reads += 1; + await gate; + return "git@github.com:coder/mux.git"; + }); + + const first = service.githubRepoInfo(); + const second = service.githubRepoInfo(); + release?.(); + await Promise.all([first, second]); + await service.githubRepoInfo(); + + expect(reads).toBe(1); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 85a3eaae8e..0780d70ff4 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -30,6 +30,11 @@ import { type CloneErrorCode, } from "./sshCloneFailure"; import type { BranchListResult } from "@/common/orpc/types"; +import type { + ConfiguredProjectGitHubRepoInfo, + GitHubRepoInfo, +} from "@/common/orpc/schemas/githubRepoInfo"; +import { parseGitHubRemote } from "@/common/utils/githubRemote"; import type { ProjectRemoveErrorSchema } from "@/common/orpc/schemas/errors"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import * as path from "path"; @@ -353,6 +358,35 @@ interface FileCompletionsCacheEntry { refreshing?: Promise; } +const GITHUB_REPO_INFO_CACHE_TTL_MS = 5 * 60 * 1000; +const GITHUB_REMOTE_TIMEOUT_MS = 2_000; + +interface GitHubRepoInfoCacheEntry { + value: GitHubRepoInfo | null; + expiresAt: number; +} + +type GitRemoteReader = (projectPath: string) => Promise; + +async function readOriginRemote(projectPath: string): Promise { + using proc = execFileAsync("git", ["-C", projectPath, "remote", "get-url", "origin"], { + timeoutMs: GITHUB_REMOTE_TIMEOUT_MS, + }); + const { stdout } = await proc.result; + return stdout.trim(); +} + +function toGitHubRepoInfo(remote: string): GitHubRepoInfo | null { + const identity = parseGitHubRemote(remote); + if (!identity) { + return null; + } + return { + ...identity, + avatarUrl: `https://github.com/${identity.owner}.png?size=64`, + }; +} + // Keep raw Node errno details out of project-add errors. function friendlyFsError(error: unknown, action: string, targetPath: string): string | null { const code = (error as NodeJS.ErrnoException).code; @@ -419,13 +453,16 @@ function hasRegisteredSubProjectAncestor( export class ProjectService { private readonly fileCompletionsCache = new Map(); + private readonly githubRepoInfoCache = new Map(); + private readonly githubRepoInfoInflight = new Map>(); private directoryPicker?: (initialPath?: string | null) => Promise; private readonly sshPromptService: SshPromptService | undefined; private workspaceService?: WorkspaceRemover; constructor( private readonly config: Config, - sshPromptService?: SshPromptService + sshPromptService?: SshPromptService, + private readonly gitRemoteReader: GitRemoteReader = readOriginRemote ) { this.sshPromptService = sshPromptService; } @@ -1286,6 +1323,50 @@ export class ProjectService { } } + async githubRepoInfo(): Promise { + const projectPaths = Array.from(this.config.loadConfigOrDefault().projects.keys()); + const configuredProjectPaths = new Set(projectPaths); + for (const cachedProjectPath of this.githubRepoInfoCache.keys()) { + if (!configuredProjectPaths.has(cachedProjectPath)) { + this.githubRepoInfoCache.delete(cachedProjectPath); + } + } + const entries = await Promise.all( + projectPaths.map( + async (projectPath) => [projectPath, await this.getGitHubRepoInfo(projectPath)] as const + ) + ); + return Object.fromEntries(entries); + } + + private async getGitHubRepoInfo(projectPath: string): Promise { + const cached = this.githubRepoInfoCache.get(projectPath); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const pending = this.githubRepoInfoInflight.get(projectPath); + if (pending) { + return pending; + } + + const request = this.gitRemoteReader(projectPath) + .then(toGitHubRepoInfo) + .catch(() => null) + .then((value) => { + this.githubRepoInfoCache.set(projectPath, { + value, + expiresAt: Date.now() + GITHUB_REPO_INFO_CACHE_TTL_MS, + }); + return value; + }) + .finally(() => { + this.githubRepoInfoInflight.delete(projectPath); + }); + this.githubRepoInfoInflight.set(projectPath, request); + return request; + } + async listBranches(projectPath: string): Promise { if (typeof projectPath !== "string" || projectPath.trim().length === 0) { throw new Error("Project path is required to list branches");