Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/browser/components/AgentListItem/AgentListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -64,6 +65,7 @@ import {
ChevronDown,
HeartPulse,
Pin,
Folder,
} from "lucide-react";
import { isWorkspacePinnable, isWorkspacePinned } from "@/common/utils/pin";
import { WorkspaceStatusIndicator } from "../WorkspaceStatusIndicator/WorkspaceStatusIndicator";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -211,6 +215,37 @@ function HeartbeatFallbackIcon() {
);
}

function ProjectAvatar(props: { info: GitHubRepoInfo | null | undefined; label: string }) {
const url = props.info?.avatarUrl;
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const failed = failedUrl === url;

if (!url || failed) {
return (
<div
className="bg-surface-secondary text-muted flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
aria-label={`${props.label} repository`}
data-testid="project-avatar-fallback"
>
<Folder className="h-4 w-4" aria-hidden="true" />
</div>
);
}

return (
<img
src={url}
alt=""
loading="lazy"
width={28}
height={28}
className="h-7 w-7 shrink-0 rounded-full object-cover"
onLoad={() => setFailedUrl(null)}
onError={() => setFailedUrl(url)}
/>
);
}

function formatSubAgentCount(count: number, label: "active" | "queued"): string {
return `${count} sub-agent${count === 1 ? "" : "s"} ${label}`;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -1175,8 +1214,15 @@ function RegularAgentListItemInner(props: AgentListItemProps) {
)
)}

{isFlatCard && <ProjectAvatar info={props.githubRepoInfo} label={repositoryLabel} />}

{/* Keep title row anchored so status dot/title align across single+double-line states. */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{isFlatCard && (
<div className="text-muted min-w-0 truncate text-left text-[11px] leading-4">
{repositoryLabel}
</div>
)}
<div
className={cn(
// Keep the title column shrinkable on narrow/mobile viewports so the
Expand Down
154 changes: 153 additions & 1 deletion src/browser/components/LeftSidebar/LeftSidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { updatePersistedState } from "@/browser/hooks/usePersistedState";
import {
SELECTED_WORKSPACE_KEY,
SIDEBAR_AGE_GROUPING_KEY,
SIDEBAR_DISPLAY_STYLE_KEY,
UI_THEME_KEY,
getWorkspaceLastReadKey,
} from "@/common/constants/storage";
Expand Down Expand Up @@ -111,6 +112,7 @@ function resetStorybookPersistedStateForStory(): void {
// FlatListWhenAgeGroupingDisabled writes this key; clear it so later
// stories are not affected by story execution order.
localStorage.removeItem(SIDEBAR_AGE_GROUPING_KEY);
localStorage.removeItem(SIDEBAR_DISPLAY_STYLE_KEY);
}
}

Expand Down Expand Up @@ -265,7 +267,88 @@ function createGitStatusExecutor(gitStatus?: Map<string, GitStatusFixture>) {
};
}

/** 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 },
Expand Down Expand Up @@ -991,6 +1074,75 @@ export const FlatListWhenAgeGroupingDisabled: AppStory = {
},
};

export const FlatCardsWithAgeGrouping: AppStory = {
parameters: {
pixel: { matrix: PIXEL_DUAL_THEME },
},
render: () => (
<LeftSidebarStoryShell setup={() => 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: () => (
<LeftSidebarStoryShell
setup={() => 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: () => (
<LeftSidebarStoryShell
leftSidebarProps={{ collapsed: false, widthPx: 390 }}
setup={() => createFlatCardsClient({ ageGrouping: false, brokenAvatar: true })}
/>
),
};

/** Long workspace names - tests truncation and prevents horizontal scroll regression */
export const LongWorkspaceNames: AppStory = {
render: () => (
Expand Down
Loading
Loading