Skip to content
Merged
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
73 changes: 29 additions & 44 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import "./styles/globals.css";
import { useWorkspaceContext } from "./contexts/WorkspaceContext";
import { useProjectContext } from "./contexts/ProjectContext";
import type { WorkspaceSelection } from "./components/ProjectSidebar";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { LeftSidebar } from "./components/LeftSidebar";
import { ProjectCreateModal } from "./components/ProjectCreateModal";
import { AIView } from "./components/AIView";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering";
import { useResumeManager } from "./hooks/useResumeManager";
import { useUnreadTracking } from "./hooks/useUnreadTracking";
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
Expand Down Expand Up @@ -198,46 +198,24 @@ function AppInner() {
// NEW: Get workspace recency from store
const workspaceRecency = useWorkspaceRecency();

// Sort workspaces by recency (most recent first)
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
// Build sorted workspaces map including pending workspaces
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
const sortedWorkspacesByProject = useStableReference(
() => {
const result = new Map<string, FrontendWorkspaceMetadata[]>();
for (const [projectPath, config] of projects) {
// Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
const metadataList = config.workspaces
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null);

// Sort by recency
metadataList.sort((a, b) => {
const aTimestamp = workspaceRecency[a.id] ?? 0;
const bTimestamp = workspaceRecency[b.id] ?? 0;
return bTimestamp - aTimestamp;
() => buildSortedWorkspacesByProject(projects, workspaceMetadata, workspaceRecency),
(prev, next) =>
compareMaps(prev, next, (a, b) => {
if (a.length !== b.length) return false;
// Check ID, name, and status to detect changes
return a.every((meta, i) => {
const other = b[i];
return (
other &&
meta.id === other.id &&
meta.name === other.name &&
meta.status === other.status
);
});

result.set(projectPath, metadataList);
}
return result;
},
(prev, next) => {
// Compare Maps: check if size, workspace order, and metadata content are the same
if (
!compareMaps(prev, next, (a, b) => {
if (a.length !== b.length) return false;
// Check both ID and name to detect renames
return a.every((metadata, i) => {
const bMeta = b[i];
if (!bMeta || !metadata) return false; // Null-safe
return metadata.id === bMeta.id && metadata.name === bMeta.name;
});
})
) {
return false;
}
return true;
},
}),
[projects, workspaceMetadata, workspaceRecency]
);

Expand Down Expand Up @@ -605,12 +583,19 @@ function AppInner() {
new Map(prev).set(metadata.id, metadata)
);

// Switch to new workspace
setSelectedWorkspace({
workspaceId: metadata.id,
projectPath: metadata.projectPath,
projectName: metadata.projectName,
namedWorkspacePath: metadata.namedWorkspacePath,
// Only switch to new workspace if user hasn't selected another one
// during the creation process (selectedWorkspace was null when creation started)
setSelectedWorkspace((current) => {
if (current !== null) {
// User has already selected another workspace - don't override
return current;
}
return {
workspaceId: metadata.id,
projectPath: metadata.projectPath,
projectName: metadata.projectName,
namedWorkspacePath: metadata.namedWorkspacePath,
};
});

// Track telemetry
Expand Down
112 changes: 68 additions & 44 deletions src/browser/components/WorkspaceListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
onToggleUnread: _onToggleUnread,
}) => {
// Destructure metadata for convenience
const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata;
const { id: workspaceId, name: workspaceName, namedWorkspacePath, status } = metadata;
const isCreating = status === "creating";
const isDisabled = isCreating || isDeleting;
const gitStatus = useGitStatus(workspaceId);

// Get rename context
Expand Down Expand Up @@ -100,19 +102,24 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
<React.Fragment>
<div
className={cn(
"py-1.5 pl-4 pr-2 cursor-pointer border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative hover:bg-hover [&:hover_button]:opacity-100 flex gap-2",
isSelected && "bg-hover border-l-blue-400",
isDeleting && "opacity-50 pointer-events-none"
"py-1.5 pl-4 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
isDisabled
? "cursor-default opacity-70"
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
isSelected && !isDisabled && "bg-hover border-l-blue-400",
isDeleting && "pointer-events-none"
)}
onClick={() =>
onClick={() => {
if (isDisabled) return;
onSelectWorkspace({
projectPath,
projectName,
namedWorkspacePath,
workspaceId,
})
}
});
}}
onKeyDown={(e) => {
if (isDisabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectWorkspace({
Expand All @@ -124,9 +131,16 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
}
}}
role="button"
tabIndex={0}
tabIndex={isDisabled ? -1 : 0}
aria-current={isSelected ? "true" : undefined}
aria-label={`Select workspace ${displayName}`}
aria-label={
isCreating
? `Creating workspace ${displayName}`
: isDeleting
? `Deleting workspace ${displayName}`
: `Select workspace ${displayName}`
}
aria-disabled={isDisabled}
data-workspace-path={namedWorkspacePath}
data-workspace-id={workspaceId}
>
Expand All @@ -147,14 +161,18 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
/>
) : (
<span
className="text-foreground -mx-1 min-w-0 flex-1 cursor-pointer truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200 hover:bg-white/5"
className={cn(
"text-foreground -mx-1 min-w-0 flex-1 truncate rounded-sm px-1 text-left text-[14px] transition-colors duration-200",
!isDisabled && "cursor-pointer hover:bg-white/5"
)}
onDoubleClick={(e) => {
if (isDisabled) return;
e.stopPropagation();
startRenaming();
}}
title="Double-click to rename"
title={isDisabled ? undefined : "Double-click to rename"}
>
{canInterrupt ? (
{canInterrupt || isCreating ? (
<Shimmer className="w-full truncate" colorClass="var(--color-foreground)">
{displayName}
</Shimmer>
Expand All @@ -165,41 +183,47 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
)}

<div className="ml-auto flex items-center gap-1">
<GitStatusIndicator
gitStatus={gitStatus}
workspaceId={workspaceId}
tooltipPosition="right"
isWorking={canInterrupt}
/>
{!isCreating && (
<>
<GitStatusIndicator
gitStatus={gitStatus}
workspaceId={workspaceId}
tooltipPosition="right"
isWorking={canInterrupt}
/>

<TooltipWrapper inline>
<button
className="text-muted hover:text-foreground col-start-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base opacity-0 transition-all duration-200 hover:rounded-sm hover:bg-white/10"
onClick={(e) => {
e.stopPropagation();
void onRemoveWorkspace(workspaceId, e.currentTarget);
}}
aria-label={`Remove workspace ${displayName}`}
data-workspace-id={workspaceId}
>
×
</button>
<Tooltip className="tooltip" align="right">
Remove workspace
</Tooltip>
</TooltipWrapper>
<TooltipWrapper inline>
<button
className="text-muted hover:text-foreground col-start-1 flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0 text-base opacity-0 transition-all duration-200 hover:rounded-sm hover:bg-white/10"
onClick={(e) => {
e.stopPropagation();
void onRemoveWorkspace(workspaceId, e.currentTarget);
}}
aria-label={`Remove workspace ${displayName}`}
data-workspace-id={workspaceId}
>
×
</button>
<Tooltip className="tooltip" align="right">
Remove workspace
</Tooltip>
</TooltipWrapper>
</>
)}
</div>
</div>
<div className="min-w-0">
{isDeleting ? (
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
<span className="-mt-0.5 shrink-0 text-[10px]">🗑️</span>
<span className="min-w-0 truncate">Deleting...</span>
</div>
) : (
<WorkspaceStatusIndicator workspaceId={workspaceId} />
)}
</div>
{!isCreating && (
<div className="min-w-0">
{isDeleting ? (
<div className="text-muted flex min-w-0 items-center gap-1.5 text-xs">
<span className="-mt-0.5 shrink-0 text-[10px]">🗑️</span>
<span className="min-w-0 truncate">Deleting...</span>
</div>
) : (
<WorkspaceStatusIndicator workspaceId={workspaceId} />
)}
</div>
)}
</div>
</div>
{renameError && isEditing && (
Expand Down
12 changes: 8 additions & 4 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export interface WorkspaceContext {

// Selection
selectedWorkspace: WorkspaceSelection | null;
setSelectedWorkspace: (workspace: WorkspaceSelection | null) => void;
setSelectedWorkspace: React.Dispatch<React.SetStateAction<WorkspaceSelection | null>>;

// Workspace creation flow
pendingNewWorkspaceProject: string | null;
Expand Down Expand Up @@ -214,6 +214,9 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
setWorkspaceMetadata((prev) => {
const updated = new Map(prev);
const isNewWorkspace = !prev.has(event.workspaceId) && event.metadata !== null;
const existingMeta = prev.get(event.workspaceId);
const wasCreating = existingMeta?.status === "creating";
const isNowReady = event.metadata !== null && event.metadata.status !== "creating";

if (event.metadata === null) {
// Workspace deleted - remove from map
Expand All @@ -223,9 +226,10 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
updated.set(event.workspaceId, event.metadata);
}

// If this is a new workspace (e.g., from fork), reload projects
// to ensure the sidebar shows the updated workspace list
if (isNewWorkspace) {
// Reload projects when:
// 1. New workspace appears (e.g., from fork)
// 2. Workspace transitions from "creating" to ready (now saved to config)
if (isNewWorkspace || (wasCreating && isNowReady)) {
void refreshProjects();
}

Expand Down
Loading