sidebar ui changes, loading state fixes (#2039)

* tons of sidebar ui cleanup, loading states etc

* lint fix

* fix archive store

* i18n

---------

Co-authored-by: Louis Knight-Webb <louis@bloop.ai>
This commit is contained in:
Theo Browne
2026-01-14 05:14:50 -08:00
committed by GitHub
parent add92d94f4
commit d54a46209b
12 changed files with 328 additions and 116 deletions

View File

@@ -7,9 +7,8 @@ import {
VirtuosoMessageListProps,
} from '@virtuoso.dev/message-list';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SpinnerGapIcon } from '@phosphor-icons/react';
import { cn } from '@/lib/utils';
import NewDisplayConversationEntry from './NewDisplayConversationEntry';
import { ApprovalFormProvider } from '@/contexts/ApprovalFormContext';
import { useEntries } from '@/contexts/EntriesContext';
@@ -86,7 +85,6 @@ const computeItemKey: VirtuosoMessageListProps<
>['computeItemKey'] = ({ data }) => `conv-${data.patchKey}`;
export function ConversationList({ attempt, task }: ConversationListProps) {
const { t } = useTranslation('common');
const [channelData, setChannelData] =
useState<DataWithScrollModifier<PatchTypeWithKey> | null>(null);
const [loading, setLoading] = useState(true);
@@ -156,8 +154,17 @@ export function ConversationList({ attempt, task }: ConversationListProps) {
[attempt, task]
);
// Determine if content is ready to show (has data or finished loading)
const hasContent = !loading || (channelData?.data?.length ?? 0) > 0;
return (
<ApprovalFormProvider>
<div
className={cn(
'h-full transition-opacity duration-300',
hasContent ? 'opacity-100' : 'opacity-0'
)}
>
<VirtuosoMessageListLicense
licenseKey={import.meta.env.VITE_PUBLIC_REACT_VIRTUOSO_LICENSE_KEY}
>
@@ -173,12 +180,7 @@ export function ConversationList({ attempt, task }: ConversationListProps) {
Footer={() => <div className="h-2" />}
/>
</VirtuosoMessageListLicense>
{loading && !channelData?.data?.length && (
<div className="absolute inset-0 bg-primary flex flex-col gap-2 justify-center items-center">
<SpinnerGapIcon className="h-8 w-8 animate-spin" />
<p>{t('states.loadingHistory')}</p>
</div>
)}
</ApprovalFormProvider>
);
}

View File

@@ -944,11 +944,7 @@ export type NavbarItem = ActionDefinition | typeof NavbarDivider;
// Navbar action groups define which actions appear in each section
export const NavbarActionGroups = {
left: [
Actions.OpenInOldUI,
NavbarDivider,
Actions.ArchiveWorkspace,
] as ActionDefinition[],
left: [Actions.OpenInOldUI] as ActionDefinition[],
right: [
Actions.ToggleDiffViewMode,
Actions.ToggleAllDiffs,

View File

@@ -545,9 +545,43 @@ export function SessionChatBoxContainer({
localMessage,
]);
// Don't render if no session and not in new session mode
if (!session && !isNewSessionMode) {
return null;
// Render placeholder state if no session and not in new session mode
// This maintains the visual structure during workspace transitions
const isPlaceholderMode = !session && !isNewSessionMode;
// In placeholder mode, render a disabled version to maintain visual structure
if (isPlaceholderMode) {
return (
<SessionChatBox
status="idle"
workspaceId={workspaceId}
projectId={projectId}
editor={{
value: '',
onChange: () => {},
}}
actions={{
onSend: () => {},
onQueue: () => {},
onCancelQueue: () => {},
onStop: () => {},
onPasteFiles: () => {},
}}
session={{
sessions: [],
selectedSessionId: undefined,
onSelectSession: () => {},
isNewSessionMode: false,
onNewSession: undefined,
}}
stats={{
filesChanged: 0,
linesAdded: 0,
linesRemoved: 0,
onViewCode: undefined,
}}
/>
);
}
return (

View File

@@ -40,6 +40,7 @@ import {
PERSIST_KEYS,
useExpandedAll,
usePaneSize,
usePersistedExpanded,
} from '@/stores/useUiPreferencesStore';
import {
useLayoutStore,
@@ -304,6 +305,10 @@ export function WorkspacesLayout() {
50
);
const isRightMainPanelVisible = useIsRightMainPanelVisible();
const [showArchive, setShowArchive] = usePersistedExpanded(
PERSIST_KEYS.workspacesSidebarArchived,
false
);
const defaultLayout = (): Layout => {
let layout = { 'left-main': 50, 'right-main': 50 };
@@ -710,6 +715,23 @@ export function WorkspacesLayout() {
);
};
// Action handlers for sidebar workspace actions
const { executeAction } = useActions();
const handleArchiveWorkspace = useCallback(
(workspaceId: string) => {
executeAction(Actions.ArchiveWorkspace, workspaceId);
},
[executeAction]
);
const handlePinWorkspace = useCallback(
(workspaceId: string) => {
executeAction(Actions.PinWorkspace, workspaceId);
},
[executeAction]
);
// Render sidebar with persisted draft title
const renderSidebar = () => (
<WorkspacesSidebar
@@ -720,9 +742,13 @@ export function WorkspacesLayout() {
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onAddWorkspace={navigateToCreate}
onArchiveWorkspace={handleArchiveWorkspace}
onPinWorkspace={handlePinWorkspace}
isCreateMode={isCreateMode}
draftTitle={persistedDraftTitle}
onSelectCreate={navigateToCreate}
showArchive={showArchive}
onShowArchiveChange={setShowArchive}
/>
);

View File

@@ -1,12 +1,13 @@
import {
PushPinIcon,
DotsThreeIcon,
HandIcon,
TriangleIcon,
PlayIcon,
FileIcon,
CircleIcon,
GitPullRequestIcon,
ArchiveIcon,
ListIcon,
} from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
@@ -30,6 +31,8 @@ interface WorkspaceSummaryProps {
latestProcessStatus?: 'running' | 'completed' | 'failed' | 'killed';
prStatus?: 'open' | 'merged' | 'closed' | 'unknown';
onClick?: () => void;
onArchive?: () => void;
onPin?: () => void;
className?: string;
summary?: boolean;
/** Whether this is a draft workspace (shows "Draft" instead of elapsed time) */
@@ -52,6 +55,8 @@ export function WorkspaceSummary({
latestProcessStatus,
prStatus,
onClick,
onArchive,
onPin,
className,
summary = false,
isDraft = false,
@@ -69,21 +74,41 @@ export function WorkspaceSummary({
});
};
const handleArchive = (e: React.MouseEvent) => {
e.stopPropagation();
onArchive?.();
};
const handlePin = (e: React.MouseEvent) => {
e.stopPropagation();
onPin?.();
};
return (
<div className={cn('group relative', className)}>
<div
className={cn(
'group relative rounded-sm transition-all duration-100 overflow-hidden',
isActive ? 'bg-tertiary' : '',
className
)}
>
{/* Selection indicator - thin colored tab on the left */}
<div
className={cn(
'absolute left-0 top-1 bottom-1 w-0.5 rounded-full transition-colors duration-100',
isActive ? 'bg-brand' : 'bg-transparent'
)}
/>
<button
onClick={onClick}
className={cn(
'flex w-full cursor-pointer flex-col border-l-4 text-left text-low',
isActive ? 'border-normal pl-base' : 'border-none'
)}
>
<div
className={cn(
'truncate group-hover:text-high pr-double',
!summary && 'text-normal'
'flex w-full cursor-pointer flex-col text-left px-base py-half transition-all duration-150',
isActive
? 'text-normal'
: 'text-low opacity-60 hover:opacity-100 hover:text-normal'
)}
>
<div className={cn('truncate pr-double', !summary && 'text-normal')}>
{name}
</div>
{(!summary || isActive) && (
@@ -179,16 +204,55 @@ export function WorkspaceSummary({
)}
</button>
{/* Right-side hover zone for action overlay */}
{workspaceId && (
<div className="absolute right-0 top-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute right-0 top-0 bottom-0 w-16 group/actions">
{/* Sliding action overlay - only appears when hovering this zone */}
<div
className={cn(
'absolute right-0 top-0 bottom-0 flex items-center',
'translate-x-full group-hover/actions:translate-x-0',
'transition-transform duration-150 ease-out'
)}
>
{/* Gradient fade from transparent to pill background */}
<div className="h-full w-6 pointer-events-none bg-gradient-to-r from-transparent to-secondary" />
{/* Action pill */}
<div className="flex items-center gap-0.5 pr-base h-full bg-secondary">
<button
onClick={handlePin}
onPointerDown={(e) => e.stopPropagation()}
className={cn(
'p-1.5 rounded-sm transition-colors duration-100',
'hover:bg-tertiary',
isPinned ? 'text-brand' : 'text-low hover:text-normal'
)}
title={isPinned ? t('workspaces.unpin') : t('workspaces.pin')}
>
<PushPinIcon
className="size-icon-xs"
weight={isPinned ? 'fill' : 'regular'}
/>
</button>
<button
onClick={handleArchive}
onPointerDown={(e) => e.stopPropagation()}
className="p-1.5 rounded-sm text-low hover:text-normal hover:bg-tertiary transition-colors duration-100"
title={t('workspaces.archive')}
>
<ArchiveIcon className="size-icon-xs" />
</button>
<button
onClick={handleOpenCommandBar}
onPointerDown={(e) => e.stopPropagation()}
className="p-half rounded-sm hover:bg-tertiary text-low hover:text-high focus:outline-none"
className="p-1.5 rounded-sm text-low hover:text-normal hover:bg-tertiary transition-colors duration-100"
title={t('workspaces.more')}
>
<DotsThreeIcon className="size-icon-sm" weight="bold" />
<ListIcon className="size-icon-xs" />
</button>
</div>
</div>
</div>
)}
</div>
);

View File

@@ -1,11 +1,9 @@
import { PlusIcon } from '@phosphor-icons/react';
import { PlusIcon, ArrowLeftIcon, ArchiveIcon } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
import type { Workspace } from '@/components/ui-new/hooks/useWorkspaces';
import { CollapsibleSection } from '@/components/ui-new/primitives/CollapsibleSection';
import { InputField } from '@/components/ui-new/primitives/InputField';
import { WorkspaceSummary } from '@/components/ui-new/primitives/WorkspaceSummary';
import { SectionHeader } from '../primitives/SectionHeader';
import { PERSIST_KEYS } from '@/stores/useUiPreferencesStore';
interface WorkspacesSidebarProps {
workspaces: Workspace[];
@@ -13,6 +11,8 @@ interface WorkspacesSidebarProps {
selectedWorkspaceId: string | null;
onSelectWorkspace: (id: string) => void;
onAddWorkspace?: () => void;
onArchiveWorkspace?: (id: string) => void;
onPinWorkspace?: (id: string) => void;
searchQuery: string;
onSearchChange: (value: string) => void;
/** Whether we're in create mode */
@@ -21,6 +21,10 @@ interface WorkspacesSidebarProps {
draftTitle?: string;
/** Handler to navigate back to create mode */
onSelectCreate?: () => void;
/** Whether to show archived workspaces */
showArchive?: boolean;
/** Handler for toggling archive view */
onShowArchiveChange?: (show: boolean) => void;
}
export function WorkspacesSidebar({
@@ -29,11 +33,15 @@ export function WorkspacesSidebar({
selectedWorkspaceId,
onSelectWorkspace,
onAddWorkspace,
onArchiveWorkspace,
onPinWorkspace,
searchQuery,
onSearchChange,
isCreateMode = false,
draftTitle,
onSelectCreate,
showArchive = false,
onShowArchiveChange,
}: WorkspacesSidebarProps) {
const { t } = useTranslation(['tasks', 'common']);
const searchLower = searchQuery.toLowerCase();
@@ -48,8 +56,11 @@ export function WorkspacesSidebar({
.filter((workspace) => workspace.name.toLowerCase().includes(searchLower))
.slice(0, isSearching ? undefined : DISPLAY_LIMIT);
const hasArchivedWorkspaces = archivedWorkspaces.length > 0;
return (
<div className="w-full h-full bg-secondary flex flex-col">
{/* Header + Search */}
<div className="flex flex-col gap-base">
<SectionHeader
title={t('common:workspaces.title')}
@@ -65,14 +76,51 @@ export function WorkspacesSidebar({
/>
</div>
</div>
<div className="flex flex-col flex-1 overflow-y-auto">
<CollapsibleSection
persistKey={PERSIST_KEYS.workspacesSidebarActive}
title={t('common:workspaces.active')}
defaultExpanded
className="p-base"
contentClassName="flex flex-col gap-base min-h-[50vh]"
>
{/* Scrollable workspace list */}
<div className="flex-1 overflow-y-auto p-base">
{showArchive ? (
/* Archived workspaces view */
<div className="flex flex-col gap-base">
<span className="text-sm font-medium text-low">
{t('common:workspaces.archived')}
</span>
{filteredArchivedWorkspaces.length === 0 ? (
<span className="text-sm text-low opacity-60">
{t('common:workspaces.noArchived')}
</span>
) : (
filteredArchivedWorkspaces.map((workspace) => (
<WorkspaceSummary
summary
key={workspace.id}
name={workspace.name}
workspaceId={workspace.id}
filesChanged={workspace.filesChanged}
linesAdded={workspace.linesAdded}
linesRemoved={workspace.linesRemoved}
isActive={selectedWorkspaceId === workspace.id}
isRunning={workspace.isRunning}
isPinned={workspace.isPinned}
hasPendingApproval={workspace.hasPendingApproval}
hasRunningDevServer={workspace.hasRunningDevServer}
hasUnseenActivity={workspace.hasUnseenActivity}
latestProcessCompletedAt={workspace.latestProcessCompletedAt}
latestProcessStatus={workspace.latestProcessStatus}
prStatus={workspace.prStatus}
onClick={() => onSelectWorkspace(workspace.id)}
onArchive={() => onArchiveWorkspace?.(workspace.id)}
onPin={() => onPinWorkspace?.(workspace.id)}
/>
))
)}
</div>
) : (
/* Active workspaces view */
<div className="flex flex-col gap-base">
<span className="text-sm font-medium text-low">
{t('common:workspaces.active')}
</span>
{draftTitle && (
<WorkspaceSummary
name={draftTitle}
@@ -99,38 +147,38 @@ export function WorkspacesSidebar({
latestProcessStatus={workspace.latestProcessStatus}
prStatus={workspace.prStatus}
onClick={() => onSelectWorkspace(workspace.id)}
onArchive={() => onArchiveWorkspace?.(workspace.id)}
onPin={() => onPinWorkspace?.(workspace.id)}
/>
))}
</CollapsibleSection>
<CollapsibleSection
persistKey={PERSIST_KEYS.workspacesSidebarArchived}
title={t('common:workspaces.archived')}
defaultExpanded
className="px-base pb-half"
>
{filteredArchivedWorkspaces.map((workspace) => (
<WorkspaceSummary
summary
key={workspace.id}
name={workspace.name}
workspaceId={workspace.id}
filesChanged={workspace.filesChanged}
linesAdded={workspace.linesAdded}
linesRemoved={workspace.linesRemoved}
isActive={selectedWorkspaceId === workspace.id}
isRunning={workspace.isRunning}
isPinned={workspace.isPinned}
hasPendingApproval={workspace.hasPendingApproval}
hasRunningDevServer={workspace.hasRunningDevServer}
hasUnseenActivity={workspace.hasUnseenActivity}
latestProcessCompletedAt={workspace.latestProcessCompletedAt}
latestProcessStatus={workspace.latestProcessStatus}
prStatus={workspace.prStatus}
onClick={() => onSelectWorkspace(workspace.id)}
/>
))}
</CollapsibleSection>
</div>
)}
</div>
{/* Fixed footer toggle - only show if there are archived workspaces */}
{hasArchivedWorkspaces && (
<div className="border-t border-primary p-base">
<button
onClick={() => onShowArchiveChange?.(!showArchive)}
className="w-full flex items-center gap-base text-sm text-low hover:text-normal transition-colors duration-100"
>
{showArchive ? (
<>
<ArrowLeftIcon className="size-icon-xs" />
<span>{t('common:workspaces.backToActive')}</span>
</>
) : (
<>
<ArchiveIcon className="size-icon-xs" />
<span>{t('common:workspaces.viewArchive')}</span>
<span className="ml-auto text-xs bg-tertiary px-1.5 py-0.5 rounded">
{archivedWorkspaces.length}
</span>
</>
)}
</button>
</div>
)}
</div>
);
}

View File

@@ -131,6 +131,13 @@
"loading": "Loading...",
"selectToStart": "Select a workspace to get started",
"draft": "Draft",
"viewArchive": "View Archive",
"backToActive": "Back to Active",
"noArchived": "No archived workspaces",
"pin": "Pin",
"unpin": "Unpin",
"archive": "Archive",
"more": "More actions",
"rename": {
"title": "Rename Workspace",
"description": "Enter a new name for this workspace.",

View File

@@ -131,6 +131,13 @@
"loading": "Cargando...",
"selectToStart": "Selecciona un espacio de trabajo para comenzar",
"draft": "Borrador",
"viewArchive": "Ver archivo",
"backToActive": "Volver a activos",
"noArchived": "No hay espacios de trabajo archivados",
"pin": "Fijar",
"unpin": "Desfijar",
"archive": "Archivar",
"more": "Más acciones",
"rename": {
"title": "Renombrar espacio de trabajo",
"description": "Ingresa un nuevo nombre para este espacio de trabajo.",

View File

@@ -131,6 +131,13 @@
"loading": "読み込み中...",
"selectToStart": "ワークスペースを選択して開始",
"draft": "下書き",
"viewArchive": "アーカイブを表示",
"backToActive": "アクティブに戻る",
"noArchived": "アーカイブされたワークスペースはありません",
"pin": "ピン留め",
"unpin": "ピン留め解除",
"archive": "アーカイブ",
"more": "その他の操作",
"rename": {
"title": "ワークスペースの名前を変更",
"description": "このワークスペースの新しい名前を入力してください。",

View File

@@ -131,6 +131,13 @@
"loading": "로딩 중...",
"selectToStart": "워크스페이스를 선택하여 시작",
"draft": "초안",
"viewArchive": "보관함 보기",
"backToActive": "활성으로 돌아가기",
"noArchived": "보관된 워크스페이스 없음",
"pin": "고정",
"unpin": "고정 해제",
"archive": "보관",
"more": "더 많은 작업",
"rename": {
"title": "워크스페이스 이름 변경",
"description": "이 워크스페이스의 새 이름을 입력하세요.",

View File

@@ -131,6 +131,13 @@
"loading": "加载中...",
"selectToStart": "选择一个工作区开始",
"draft": "草稿",
"viewArchive": "查看归档",
"backToActive": "返回活跃",
"noArchived": "没有已归档的工作区",
"pin": "置顶",
"unpin": "取消置顶",
"archive": "归档",
"more": "更多操作",
"rename": {
"title": "重命名工作区",
"description": "输入此工作区的新名称。",

View File

@@ -131,6 +131,13 @@
"loading": "載入中...",
"selectToStart": "選擇一個工作區開始",
"draft": "草稿",
"viewArchive": "檢視封存",
"backToActive": "返回活躍",
"noArchived": "沒有已封存的工作區",
"pin": "釘選",
"unpin": "取消釘選",
"archive": "封存",
"more": "更多操作",
"rename": {
"title": "重新命名工作區",
"description": "輸入此工作區的新名稱。",