mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
refactor: rename variables for clarity in ReviewSessionPage and SessionsPage fix: update version check logic in SettingsPage chore: remove unused imports in TeamPage and session-detail components fix: enhance error handling in MCP server fix: apply default mode in edit-file tool handler chore: remove tsbuildinfo file docs: add Quick Plan & Execute phase documentation for issue discovery chore: clean up ping output file
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
// ========================================
|
|
// SessionsPage Component
|
|
// ========================================
|
|
// Sessions list page with CRUD operations
|
|
|
|
import * as React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useIntl } from 'react-intl';
|
|
import {
|
|
RefreshCw,
|
|
Search,
|
|
Filter,
|
|
AlertCircle,
|
|
FolderKanban,
|
|
X,
|
|
} from 'lucide-react';
|
|
import {
|
|
useSessions,
|
|
useArchiveSession,
|
|
useDeleteSession,
|
|
type SessionsFilter,
|
|
} from '@/hooks/useSessions';
|
|
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Input } from '@/components/ui/Input';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/Dialog';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuLabel,
|
|
} from '@/components/ui/Dropdown';
|
|
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
|
import { cn } from '@/lib/utils';
|
|
import type { SessionMetadata } from '@/types/store';
|
|
|
|
type LocationFilter = 'all' | 'active' | 'archived';
|
|
|
|
// Status label keys for i18n (maps snake_case status to camelCase translation keys)
|
|
const statusLabelKeys: Record<SessionMetadata['status'], string> = {
|
|
planning: 'sessions.status.planning',
|
|
in_progress: 'sessions.status.inProgress',
|
|
completed: 'sessions.status.completed',
|
|
archived: 'sessions.status.archived',
|
|
paused: 'sessions.status.paused',
|
|
};
|
|
|
|
/**
|
|
* SessionsPage component - Sessions list with CRUD operations
|
|
*/
|
|
export function SessionsPage() {
|
|
const { formatMessage } = useIntl();
|
|
const navigate = useNavigate();
|
|
|
|
// Filter state
|
|
const [locationFilter, setLocationFilter] = React.useState<LocationFilter>('active');
|
|
const [searchQuery, setSearchQuery] = React.useState('');
|
|
const [statusFilter, setStatusFilter] = React.useState<SessionMetadata['status'][]>([]);
|
|
|
|
// Dialog state
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
|
const [sessionToDelete, setSessionToDelete] = React.useState<string | null>(null);
|
|
|
|
// Build filter object
|
|
const filter: SessionsFilter = React.useMemo(
|
|
() => ({
|
|
location: locationFilter,
|
|
search: searchQuery,
|
|
status: statusFilter.length > 0 ? statusFilter : undefined,
|
|
}),
|
|
[locationFilter, searchQuery, statusFilter]
|
|
);
|
|
|
|
// Fetch sessions with filter
|
|
const {
|
|
filteredSessions,
|
|
isLoading,
|
|
isFetching,
|
|
error,
|
|
refetch,
|
|
} = useSessions({ filter });
|
|
|
|
// Mutations
|
|
const { archiveSession, isArchiving } = useArchiveSession();
|
|
const { deleteSession, isDeleting } = useDeleteSession();
|
|
|
|
const isMutating = isArchiving || isDeleting;
|
|
|
|
// Handlers
|
|
const handleSessionClick = (sessionId: string, sessionType?: SessionMetadata['type']) => {
|
|
// Route review sessions to the dedicated review page
|
|
if (sessionType === 'review') {
|
|
navigate(`/sessions/${sessionId}/review`);
|
|
} else {
|
|
navigate(`/sessions/${sessionId}`);
|
|
}
|
|
};
|
|
|
|
const handleArchive = async (sessionId: string) => {
|
|
try {
|
|
await archiveSession(sessionId);
|
|
} catch (err) {
|
|
console.error('Failed to archive session:', err);
|
|
}
|
|
};
|
|
|
|
const handleDeleteClick = (sessionId: string) => {
|
|
setSessionToDelete(sessionId);
|
|
setDeleteDialogOpen(true);
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (!sessionToDelete) return;
|
|
|
|
try {
|
|
await deleteSession(sessionToDelete);
|
|
setDeleteDialogOpen(false);
|
|
setSessionToDelete(null);
|
|
} catch (err) {
|
|
console.error('Failed to delete session:', err);
|
|
}
|
|
};
|
|
|
|
const handleClearSearch = () => {
|
|
setSearchQuery('');
|
|
};
|
|
|
|
const toggleStatusFilter = (status: SessionMetadata['status']) => {
|
|
setStatusFilter((prev) =>
|
|
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
|
);
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setStatusFilter([]);
|
|
setSearchQuery('');
|
|
};
|
|
|
|
const hasActiveFilters = statusFilter.length > 0 || searchQuery.length > 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-foreground">{formatMessage({ id: 'sessions.title' })}</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{formatMessage({ id: 'sessions.description' })}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => refetch()}
|
|
disabled={isFetching}
|
|
>
|
|
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
|
{formatMessage({ id: 'common.actions.refresh' })}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error alert */}
|
|
{error && (
|
|
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
|
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
|
|
<p className="text-xs mt-0.5">{error.message}</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
|
{formatMessage({ id: 'home.errors.retry' })}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
|
{/* Location tabs */}
|
|
<TabsNavigation
|
|
value={locationFilter}
|
|
onValueChange={(v) => setLocationFilter(v as LocationFilter)}
|
|
tabs={[
|
|
{ value: 'active', label: formatMessage({ id: 'sessions.filters.active' }) },
|
|
{ value: 'archived', label: formatMessage({ id: 'sessions.filters.archived' }) },
|
|
{ value: 'all', label: formatMessage({ id: 'sessions.filters.all' }) },
|
|
]}
|
|
/>
|
|
|
|
{/* Search input */}
|
|
<div className="flex-1 max-w-sm relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={formatMessage({ id: 'sessions.searchPlaceholder' })}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9 pr-9"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={handleClearSearch}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status filter dropdown */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Filter className="h-4 w-4" />
|
|
{formatMessage({ id: 'common.actions.filter' })}
|
|
{statusFilter.length > 0 && (
|
|
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
|
|
{statusFilter.length}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
<DropdownMenuLabel>{formatMessage({ id: 'common.status.label' })}</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{(['planning', 'in_progress', 'completed', 'paused'] as const).map((status) => (
|
|
<DropdownMenuItem
|
|
key={status}
|
|
onClick={() => toggleStatusFilter(status)}
|
|
className="justify-between"
|
|
>
|
|
<span>{formatMessage({ id: statusLabelKeys[status] })}</span>
|
|
{statusFilter.includes(status) && (
|
|
<span className="text-primary">✓</span>
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
{hasActiveFilters && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={clearFilters} className="text-destructive">
|
|
{formatMessage({ id: 'common.actions.clearFilters' })}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
{/* Active filters display */}
|
|
{hasActiveFilters && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'common.actions.filters' })}:</span>
|
|
{statusFilter.map((status) => (
|
|
<Badge
|
|
key={status}
|
|
variant="secondary"
|
|
className="cursor-pointer"
|
|
onClick={() => toggleStatusFilter(status)}
|
|
>
|
|
{formatMessage({ id: statusLabelKeys[status] })}
|
|
<X className="ml-1 h-3 w-3" />
|
|
</Badge>
|
|
))}
|
|
{searchQuery && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="cursor-pointer"
|
|
onClick={handleClearSearch}
|
|
>
|
|
{formatMessage({ id: 'common.actions.search' })}: {searchQuery}
|
|
<X className="ml-1 h-3 w-3" />
|
|
</Badge>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-6 text-xs">
|
|
{formatMessage({ id: 'common.actions.clearAll' })}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sessions grid */}
|
|
{isLoading ? (
|
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
|
{Array.from({ length: 9 }).map((_, i) => (
|
|
<SessionCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : filteredSessions.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 px-4 border border-dashed border-border rounded-lg">
|
|
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-medium text-foreground mb-1">
|
|
{hasActiveFilters ? formatMessage({ id: 'sessions.emptyState.title' }) : formatMessage({ id: 'sessions.emptyState.title' })}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
|
|
{hasActiveFilters
|
|
? formatMessage({ id: 'sessions.emptyState.message' })
|
|
: formatMessage({ id: 'sessions.emptyState.createFirst' })}
|
|
</p>
|
|
{hasActiveFilters && (
|
|
<Button variant="outline" onClick={clearFilters}>
|
|
{formatMessage({ id: 'common.actions.clearFilters' })}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
|
{filteredSessions.map((session) => (
|
|
<SessionCard
|
|
key={session.session_id}
|
|
session={session}
|
|
onClick={(sessionId) => handleSessionClick(sessionId, session.type)}
|
|
onView={(sessionId) => handleSessionClick(sessionId, session.type)}
|
|
onArchive={handleArchive}
|
|
onDelete={handleDeleteClick}
|
|
actionsDisabled={isMutating}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{formatMessage({ id: 'common.dialog.deleteSession' })}</DialogTitle>
|
|
<DialogDescription>
|
|
{formatMessage({ id: 'common.dialog.deleteConfirm' })}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setDeleteDialogOpen(false);
|
|
setSessionToDelete(null);
|
|
}}
|
|
>
|
|
{formatMessage({ id: 'common.actions.cancel' })}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleConfirmDelete}
|
|
disabled={isDeleting}
|
|
>
|
|
{isDeleting ? formatMessage({ id: 'common.status.deleting' }) : formatMessage({ id: 'common.actions.delete' })}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default SessionsPage;
|