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
426 changes: 390 additions & 36 deletions apps/web/src/components/cloud-agent-next/ChatSidebar.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
} from '@/components/ui/alert-dialog';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useClosedWorktreeChatTabs } from './hooks/useClosedWorktreeChatTabs';
import { useWorkspaceFolders } from './hooks/useWorkspaceFolders';
import {
getClosedWorktreeChatTabsStorageKey,
getClosedWorktreeChatSessionIds,
Expand Down Expand Up @@ -160,6 +161,7 @@ export function CloudSidebarLayout({
const searchParams = useSearchParams();
const currentSessionId = searchParams.get('sessionId') ?? undefined;
const worktreeIdFromParams = searchParams.get('worktreeId');
const workspaceFolders = useWorkspaceFolders(currentUserId, organizationId);
const {
closedSessionIds,
sessionOrderByWorktree,
Expand Down Expand Up @@ -381,6 +383,7 @@ export function CloudSidebarLayout({
deleteSessionFromStore,
});
toast('Session deleted successfully');
await queryClient.invalidateQueries(trpc.workspaceFolders.list.pathFilter());
await invalidateSessionQueries({ queryClient, trpc });
},
onError: error => {
Expand Down Expand Up @@ -641,6 +644,7 @@ export function CloudSidebarLayout({
router.push(organizationId ? `/organizations/${organizationId}/cloud` : '/cloud');
}
void invalidateSessionQueries({ queryClient, trpc });
void queryClient.invalidateQueries(trpc.workspaceFolders.list.pathFilter());
toast.success('Worktree deleted');
} catch (error) {
toast.error('Failed to delete worktree', { description: formatSessionError(error) });
Expand Down Expand Up @@ -690,6 +694,8 @@ export function CloudSidebarLayout({
<SheetTitle>Sessions</SheetTitle>
</SheetHeader>
<ChatSidebar
key={`mobile:${currentUserId}:${organizationId ?? 'personal'}`}
workspaceFolders={workspaceFolders}
sessions={sidebarSessions}
currentSessionId={currentSessionId}
selectedWorktreeId={selectedWorktreeId}
Expand Down Expand Up @@ -722,6 +728,8 @@ export function CloudSidebarLayout({
{/* Desktop Sidebar */}
<div className="hidden w-80 shrink-0 border-r lg:block">
<ChatSidebar
key={`desktop:${currentUserId}:${organizationId ?? 'personal'}`}
workspaceFolders={workspaceFolders}
sessions={sidebarSessions}
currentSessionId={currentSessionId}
selectedWorktreeId={selectedWorktreeId}
Expand Down
136 changes: 136 additions & 0 deletions apps/web/src/components/cloud-agent-next/WorkspaceFolderDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
'use client';

import { useId, useState, type FormEvent } from 'react';
import { Check, LoaderCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
workspaceFolderNameSchema,
type WorkspaceFolder,
type WorkspaceFolderColor,
} from '@/lib/cloud-agent/workspace-folders';
import { workspaceFolderColors } from './workspace-folders';

export function WorkspaceFolderDialog({
folder,
isSaving,
onSave,
onClose,
}: {
folder: WorkspaceFolder | null;
isSaving: boolean;
onSave: (values: { name: string; color: WorkspaceFolderColor }) => Promise<boolean>;
onClose: () => void;
}) {
const [name, setName] = useState(folder?.name ?? '');
const [color, setColor] = useState<WorkspaceFolderColor>(folder?.color ?? 'default');
const [error, setError] = useState<string | null>(null);
const nameId = useId();
const errorId = useId();
const colorId = useId();

const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (isSaving) return;
const parsed = workspaceFolderNameSchema.safeParse(name);
if (!parsed.success) {
setError('Enter a folder name between 1 and 200 characters.');
return;
}
setError(null);
if (await onSave({ name: parsed.data, color })) {
onClose();
} else {
setError('Could not save this folder. Please try again.');
}
};

return (
<Dialog
open
onOpenChange={open => {
if (!open && !isSaving) onClose();
}}
>
<DialogContent className="sm:max-w-md" showCloseButton={!isSaving}>
<DialogHeader>
<DialogTitle>{folder ? 'Edit folder' : 'New folder'}</DialogTitle>
<DialogDescription>
Organize your workspaces. Folders are private to you.
</DialogDescription>
</DialogHeader>
<form onSubmit={event => void handleSubmit(event)} className="space-y-4">
<div className="space-y-2">
<label htmlFor={nameId} className="text-sm font-medium">
Folder name
</label>
<Input
id={nameId}
autoFocus
autoComplete="off"
maxLength={200}
value={name}
disabled={isSaving}
aria-invalid={Boolean(error)}
aria-describedby={error ? errorId : undefined}
onChange={event => {
setName(event.target.value);
setError(null);
}}
/>
{error && (
<p id={errorId} role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</div>
<fieldset disabled={isSaving} className="space-y-2">
<legend className="text-sm font-medium">Folder color</legend>
<div className="flex flex-wrap gap-1">
{workspaceFolderColors.map(option => (
<label key={option.value} title={option.label} className="cursor-pointer">
<input
type="radio"
name={colorId}
value={option.value}
aria-label={option.label}
checked={color === option.value}
onChange={() => setColor(option.value)}
className="peer sr-only"
/>
<span className="hover:bg-accent peer-checked:bg-accent peer-focus-visible:ring-ring flex size-11 items-center justify-center rounded-md peer-focus-visible:ring-2 peer-disabled:opacity-50">
<span
className="flex size-6 items-center justify-center rounded-full"
style={{ backgroundColor: option.css }}
>
{color === option.value && (
<Check aria-hidden="true" className="text-background size-4" />
)}
</span>
</span>
</label>
))}
</div>
</fieldset>
<DialogFooter>
<Button type="button" variant="outline" disabled={isSaving} onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving && <LoaderCircle className="size-4 animate-spin" />}
{isSaving ? 'Saving...' : folder ? 'Save changes' : 'Create folder'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
199 changes: 199 additions & 0 deletions apps/web/src/components/cloud-agent-next/WorkspaceFolderSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
'use client';

import type { DragEventHandler, ReactNode } from 'react';
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronRight,
Folder,
MoreHorizontal,
Palette,
Pencil,
Trash2,
} from 'lucide-react';
import type { WorkspaceFolder } from '@/lib/cloud-agent/workspace-folders';
import { workspaceFolderColorSchema } from '@/lib/cloud-agent/workspace-folders';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { WorkspaceFolderController } from './hooks/useWorkspaceFolders';
import { getWorkspaceFolderColor, workspaceFolderColors } from './workspace-folders';

export function WorkspaceFolderSection({
folder,
controller,
isFirst,
isLast,
isDragging,
dropPlacement,
visibleCount,
onEdit,
onDelete,
onMove,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
children,
}: {
folder: WorkspaceFolder;
controller: WorkspaceFolderController;
isFirst: boolean;
isLast: boolean;
isDragging: boolean;
dropPlacement: 'inside' | 'before' | 'after' | null;
visibleCount: number;
onEdit: () => void;
onDelete: () => void;
onMove: (direction: 'up' | 'down') => void;
onDragStart: DragEventHandler<HTMLDivElement>;
onDragEnd: DragEventHandler<HTMLDivElement>;
onDragOver: DragEventHandler<HTMLElement>;
onDragLeave: DragEventHandler<HTMLElement>;
onDrop: DragEventHandler<HTMLElement>;
children: ReactNode;
}) {
const collapsed = controller.collapsedFolderIds.includes(folder.id);
const disabled = controller.isSaving || controller.isError;
const color = getWorkspaceFolderColor(folder.color);

return (
<section
aria-label={`Folder ${folder.name}`}
data-folder-id={folder.id}
onDragEnter={onDragOver}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn(
'relative mb-2 rounded-md',
isDragging && 'opacity-50',
dropPlacement === 'inside' && 'bg-accent/50 ring-ring ring-1',
dropPlacement === 'before' &&
'before:bg-primary before:absolute before:inset-x-0 before:-top-0.5 before:h-0.5 before:rounded-full',
dropPlacement === 'after' &&
'after:bg-primary after:absolute after:inset-x-0 after:-bottom-0.5 after:h-0.5 after:rounded-full'
)}
>
<div
draggable={!disabled}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
className="group/folder hover:bg-accent flex items-center gap-1 rounded-md pr-1 transition-colors"
>
<button
type="button"
aria-label={`${collapsed ? 'Expand' : 'Collapse'} folder ${folder.name}`}
aria-expanded={!collapsed}
onClick={() => controller.toggleFolder(folder.id)}
className="focus-visible:ring-ring flex min-h-9 min-w-0 flex-1 cursor-grab items-center gap-2 rounded-md px-2 text-left text-sm focus-visible:ring-2 focus-visible:outline-none active:cursor-grabbing [@media(any-pointer:coarse)]:min-h-11"
>
{collapsed ? (
<ChevronRight className="text-muted-foreground size-3.5 shrink-0" />
) : (
<ChevronDown className="text-muted-foreground size-3.5 shrink-0" />
)}
<Folder aria-hidden="true" className="size-4 shrink-0" style={{ color }} />
<span className="min-w-0 flex-1 truncate font-medium" title={folder.name}>
{folder.name}
</span>
<span
className="text-muted-foreground text-xs tabular-nums"
title={`${visibleCount} of ${folder.worktreeIds.length} workspaces shown`}
>
{folder.worktreeIds.length}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Folder actions for ${folder.name}`}
aria-busy={controller.isSaving || undefined}
onDragStart={event => {
event.preventDefault();
event.stopPropagation();
}}
className="text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring flex size-7 shrink-0 items-center justify-center rounded-md focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 [@media(any-pointer:coarse)]:size-11"
>
<MoreHorizontal className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={disabled} onSelect={onEdit}>
<Pencil className="size-4" />
Rename folder
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={disabled} className="gap-2">
<Palette className="text-muted-foreground size-4" />
Color
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-(--radix-dropdown-menu-content-available-height) overflow-y-auto">
<DropdownMenuRadioGroup
value={folder.color}
onValueChange={value => {
const parsed = workspaceFolderColorSchema.safeParse(value);
if (parsed.success) void controller.setFolderColor(folder.id, parsed.data);
}}
>
{workspaceFolderColors.map(option => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
disabled={disabled}
>
<span
aria-hidden="true"
className="size-2.5 rounded-full"
style={{ backgroundColor: option.css }}
/>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={disabled || isFirst} onSelect={() => onMove('up')}>
<ArrowUp className="size-4" />
Move up
</DropdownMenuItem>
<DropdownMenuItem disabled={disabled || isLast} onSelect={() => onMove('down')}>
<ArrowDown className="size-4" />
Move down
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={disabled} onSelect={onDelete}>
<Trash2 className="size-4" />
Delete folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{!collapsed && (
<div className="ml-3.5 pl-1">
{visibleCount > 0 ? (
children
) : (
<p className="text-muted-foreground px-3 py-3 text-xs">
{folder.worktreeIds.length > 0 ? 'No matching workspaces' : 'Drop workspaces here'}
</p>
)}
</div>
)}
</section>
);
}
Loading