mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
Mobile support for workspace view + UI improvements (#33)
* feat: implement starred files functionality in workspace - Added state management for starred files, allowing users to star and unstar files. - Updated local storage handling to persist starred files across sessions. - Enhanced sidebar to display starred files, with a collapsed view for better accessibility. - Refactored sidebar animation logic for improved performance and user experience. * feat: enhance FileTree component with starring functionality - Added support for starring and unstarring files within the FileTree component. - Integrated starred files state management in the WorkspacePage, persisting changes to local storage. - Updated UI to display starred files in a dedicated section with both collapsed and expanded views. - Refactored FileTree and FileTreeItem components to accommodate new props for starring functionality. * refactor: remove starring functionality from FileTree and update MarkdownViewer - Removed starred file management from FileTree and FileTreeItem components. - Updated MarkdownViewer to include starring functionality with a star button. - Adjusted WorkspacePage to handle starred files display and interactions. - Cleaned up related props and UI elements for improved clarity and performance. * feat: add mobile components and safe area padding for improved mobile experience - Introduced MobileBottomToolbar, MobileFileDrawer, and MobilePathSheet components for enhanced mobile functionality. - Updated WorkspacePage to conditionally render mobile components based on device type. - Added safe area padding in styles.css to accommodate iOS devices. * feat: update MobileBottomToolbar to include currentPath prop and enhance UI elements - Added currentPath prop to MobileBottomToolbar for displaying the current workspace path. - Improved button styles and layout for better mobile usability. - Adjusted icon sizes and spacing for a more consistent appearance across buttons. * fix: update text color in MobileBottomToolbar based on currentPath prop - Changed text color in the MobileBottomToolbar to reflect the presence of currentPath, enhancing visual feedback for users. - Ensured consistency in UI elements for improved user experience. * feat: enhance MobilePathSheet and WorkspacePage with validation logic - Added validatedPath and pathValid props to MobilePathSheet for improved path validation handling. - Updated button states in MobilePathSheet and WorkspacePage to reflect path validation status, enhancing user feedback. - Improved input styling and layout for better usability on mobile devices. * feat: integrate NavTabs component into MonitorPage and WorkspacePage - Replaced existing navigation tab implementation with the new NavTabs component for a consistent UI across pages. - Simplified the navigation structure by removing redundant tab elements while maintaining connection status visibility. * refactor: simplify SessionList component structure and improve UI transitions - Replaced motion.button with a standard button for better performance. - Enhanced header and search input visibility with smoother opacity transitions. - Updated platform filter buttons to improve accessibility and visual feedback. - Refactored session list rendering for improved animation consistency and clarity. * refactor: improve SessionList and NavTabs components for better UI and functionality - Enhanced SessionList component with improved header visibility and transition effects. - Updated NavTabs to include dropdown functionality with better accessibility and user interaction. - Refactored styles for both components to ensure consistency and responsiveness across different states. * refactor: update NavTabs component styles for improved UI consistency - Increased font size for terminal header and footer text for better readability. - Removed decorative dots from the terminal header to streamline the design. - Adjusted padding in the terminal footer for a more balanced layout. * fix: remove workspace sidebar scrollbar * feat: add mobile components to MonitorPage for enhanced mobile experience - Introduced MobileSessionDrawer and MobileMonitorToolbar components for improved mobile functionality. - Updated MonitorPage to conditionally render mobile components based on device type. - Enhanced layout and state management for mobile interactions.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { PanelLeft, Settings, Trash2 } from 'lucide-react'
|
||||
import { StatusIndicator } from './StatusIndicator'
|
||||
|
||||
interface MobileMonitorToolbarProps {
|
||||
onOpenDrawer: () => void
|
||||
onOpenSettings: () => void
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
sessionCount: number
|
||||
actionCount: number
|
||||
completedCount: number
|
||||
onClearCompleted: () => void
|
||||
}
|
||||
|
||||
export function MobileMonitorToolbar({
|
||||
onOpenDrawer,
|
||||
onOpenSettings,
|
||||
connected,
|
||||
connecting,
|
||||
sessionCount,
|
||||
actionCount,
|
||||
completedCount,
|
||||
onClearCompleted,
|
||||
}: MobileMonitorToolbarProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="fixed bottom-0 left-0 right-0 z-40 sm:hidden"
|
||||
>
|
||||
<div className="bg-shell-900 border-t border-shell-800 px-3 pt-3 pb-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Sessions button */}
|
||||
<button
|
||||
onClick={onOpenDrawer}
|
||||
className="relative p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<PanelLeft size={22} />
|
||||
{sessionCount > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-crab-600 text-white text-[10px] font-display rounded-full">
|
||||
{sessionCount > 99 ? '99+' : sessionCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Stats display */}
|
||||
<div className="flex-1 flex items-center justify-center gap-4 px-3 py-2 bg-shell-800/50 rounded-lg min-h-[44px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} size="sm" />
|
||||
<span className="font-console text-xs text-shell-400">
|
||||
{connecting ? 'connecting' : connected ? 'connected' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-shell-700" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-display text-sm text-neon-peach">{actionCount}</span>
|
||||
<span className="font-console text-[10px] text-shell-500 uppercase">acts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear completed button */}
|
||||
{completedCount > 0 && (
|
||||
<button
|
||||
onClick={onClearCompleted}
|
||||
className="relative p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={22} />
|
||||
<span className="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-shell-700 text-shell-300 text-[10px] font-display rounded-full">
|
||||
{completedCount > 99 ? '99+' : completedCount}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Settings button */}
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<Settings size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Users, ChevronDown, Github, Search } from 'lucide-react'
|
||||
import { StatusIndicator } from './StatusIndicator'
|
||||
import type { MonitorSession } from '~/integrations/openclaw'
|
||||
|
||||
function isSubagent(session: MonitorSession): boolean {
|
||||
return Boolean(session.spawnedBy) || session.platform === 'subagent' || session.key.includes('subagent')
|
||||
}
|
||||
|
||||
function XIcon({ size = 14, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={['footer-icon-x', className].filter(Boolean).join(' ')}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const platformEmoji: Record<string, string> = {
|
||||
whatsapp: '💬',
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
slack: '💼',
|
||||
}
|
||||
|
||||
interface MobileSessionDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
sessions: MonitorSession[]
|
||||
selectedKey: string | null
|
||||
onSelect: (key: string) => void
|
||||
}
|
||||
|
||||
function SubagentItem({
|
||||
session,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
session: MonitorSession
|
||||
selected: boolean
|
||||
onSelect: (key: string) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left py-3 pr-4 pl-8 border-b border-shell-800/50 transition-all duration-150 ${
|
||||
selected
|
||||
? 'bg-neon-cyan/5 border-l-2 border-l-neon-cyan'
|
||||
: 'active:bg-shell-800/30 border-l-2 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="font-display text-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
|
||||
subagent
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-base">🤖</span>
|
||||
<span className="font-console text-sm text-shell-400 truncate flex-1">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileSessionDrawer({
|
||||
open,
|
||||
onClose,
|
||||
sessions,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
}: MobileSessionDrawerProps) {
|
||||
const [filter, setFilter] = useState('')
|
||||
const [platformFilter, setPlatformFilter] = useState<string | null>(null)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const parentSessions = sessions.filter((s) => !isSubagent(s))
|
||||
const platforms = [...new Set(parentSessions.map((s) => s.platform))]
|
||||
|
||||
const filteredParents = parentSessions.filter((session) => {
|
||||
const matchesText =
|
||||
!filter ||
|
||||
session.recipient.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
session.agentId.toLowerCase().includes(filter.toLowerCase())
|
||||
const matchesPlatform = !platformFilter || session.platform === platformFilter
|
||||
return matchesText && matchesPlatform
|
||||
})
|
||||
|
||||
const sortedParents = [...filteredParents].sort((a, b) => {
|
||||
if (a.status !== 'idle' && b.status === 'idle') return -1
|
||||
if (a.status === 'idle' && b.status !== 'idle') return 1
|
||||
return b.lastActivityAt - a.lastActivityAt
|
||||
})
|
||||
|
||||
const { subagentsByParent, orphanSubagents } = useMemo(() => {
|
||||
const byParent = new Map<string, MonitorSession[]>()
|
||||
const orphans: MonitorSession[] = []
|
||||
const parentKeys = new Set(parentSessions.map((s) => s.key))
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!isSubagent(session)) continue
|
||||
const matchesFilter =
|
||||
!filter ||
|
||||
session.agentId.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
'subagent'.includes(filter.toLowerCase())
|
||||
if (!matchesFilter) continue
|
||||
|
||||
if (session.spawnedBy && parentKeys.has(session.spawnedBy)) {
|
||||
const list = byParent.get(session.spawnedBy) ?? []
|
||||
list.push(session)
|
||||
byParent.set(session.spawnedBy, list)
|
||||
} else {
|
||||
orphans.push(session)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, list] of byParent) {
|
||||
list.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
||||
byParent.set(key, list)
|
||||
}
|
||||
orphans.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
||||
|
||||
return { subagentsByParent: byParent, orphanSubagents: orphans }
|
||||
}, [sessions, parentSessions, filter])
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
onSelect(key)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
{/* Sheet - slides up from bottom */}
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 280 }}
|
||||
className="fixed inset-x-0 bottom-0 z-50 flex flex-col bg-shell-900 rounded-t-2xl max-h-[85vh]"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div className="w-10 h-1 rounded-full bg-shell-600" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 pb-3 border-b border-shell-800">
|
||||
<h2 className="font-mono uppercase text-sm text-crab-400 tracking-wider">
|
||||
Sessions
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 -mr-2 active:bg-shell-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={24} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-3 border-b border-shell-800">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter sessions..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full bg-shell-800 border border-shell-700 rounded-lg pl-9 pr-3 py-2.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platform filters */}
|
||||
{platforms.length > 1 && (
|
||||
<div className="flex gap-2 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => setPlatformFilter(null)}
|
||||
className={`px-3 py-1.5 text-xs font-display uppercase tracking-wide rounded-lg border transition-all ${
|
||||
!platformFilter
|
||||
? 'bg-crab-600 border-crab-500 text-white'
|
||||
: 'bg-shell-800 border-shell-700 text-gray-400 active:border-shell-600'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{platforms.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPlatformFilter(p)}
|
||||
className={`px-3 py-1.5 text-xs font-display uppercase tracking-wide rounded-lg border transition-all ${
|
||||
platformFilter === p
|
||||
? 'bg-crab-600 border-crab-500 text-white'
|
||||
: 'bg-shell-800 border-shell-700 text-gray-400 active:border-shell-600'
|
||||
}`}
|
||||
>
|
||||
{platformEmoji[p] || '📱'} {p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain">
|
||||
<AnimatePresence mode="sync">
|
||||
{sortedParents.map((session) => (
|
||||
<motion.div
|
||||
key={session.key}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSelect(session.key)}
|
||||
className={`w-full text-left p-4 border-b border-shell-800 transition-all duration-150 ${
|
||||
selectedKey === session.key
|
||||
? 'bg-crab-900/20 border-l-2 border-l-crab-500'
|
||||
: 'active:bg-shell-800/50 border-l-2 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
|
||||
main
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-xl">
|
||||
{platformEmoji[session.platform] || '📱'}
|
||||
</span>
|
||||
<span className="font-display text-sm font-medium text-gray-200 truncate flex-1 uppercase tracking-wide">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-xs text-shell-500 truncate flex-1">
|
||||
{session.agentId}
|
||||
</span>
|
||||
{session.isGroup && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-shell-800 border border-shell-700 rounded text-xs text-shell-400">
|
||||
<Users size={12} />
|
||||
group
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Nested subagents */}
|
||||
{(() => {
|
||||
const subs = subagentsByParent.get(session.key)
|
||||
if (!subs?.length) return null
|
||||
const isGroupCollapsed = collapsedGroups.has(session.key)
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(session.key)) next.delete(session.key)
|
||||
else next.add(session.key)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left flex items-center gap-2 text-xs font-display uppercase tracking-widest text-shell-500 active:text-shell-300 active:bg-shell-800/30 border-b border-shell-800/50"
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform ${isGroupCollapsed ? '-rotate-90' : ''}`}
|
||||
/>
|
||||
<span>
|
||||
{subs.length} subagent{subs.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</button>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isGroupCollapsed ? 0 : 'auto',
|
||||
opacity: isGroupCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{subs.map((sub) => (
|
||||
<SubagentItem
|
||||
key={sub.key}
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Orphan subagents */}
|
||||
{orphanSubagents.map((sub) => (
|
||||
<motion.div
|
||||
key={sub.key}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<SubagentItem
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{sortedParents.length === 0 && orphanSubagents.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<div className="font-console text-sm text-shell-500">
|
||||
<span className="text-crab-600">></span> no sessions found
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-shell-800 bg-shell-950/50">
|
||||
<div className="font-console text-xs text-shell-500 text-center flex items-center justify-center gap-6">
|
||||
<a
|
||||
href="https://github.com/luccast/crabwalk"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-shell-500 active:text-crab-500 transition-colors"
|
||||
>
|
||||
<Github size={14} />
|
||||
<span>Github</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/luccasveg"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-shell-500 active:text-crab-500 transition-colors"
|
||||
>
|
||||
<XIcon size={14} />
|
||||
<span>@luccasveg</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -57,9 +57,7 @@ function SubagentItem({
|
||||
onSelect: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
initial={false}
|
||||
animate={{ opacity: 1 }}
|
||||
<button
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left border-b border-shell-800/50 transition-all duration-150 group ${
|
||||
collapsed ? "p-2" : "py-2 pr-3 pl-6"
|
||||
@@ -89,7 +87,7 @@ function SubagentItem({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,16 +166,18 @@ export function SessionList({
|
||||
<div className="absolute inset-0 texture-scanlines pointer-events-none opacity-50" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative p-3 bg-shell-950/50">
|
||||
<div className={`flex items-center justify-between ${collapsed ? "" : "mb-3"}`}>
|
||||
{!collapsed && (
|
||||
<h2 className="font-mono uppercase text-sm text-crab-400 glow-red tracking-wider ml-1">
|
||||
Sessions
|
||||
</h2>
|
||||
)}
|
||||
<div className="relative p-3 bg-shell-950/50 overflow-hidden">
|
||||
<div className={`flex items-center mb-3 ${collapsed ? "justify-center" : "justify-between"}`}>
|
||||
<h2
|
||||
className={`font-mono uppercase text-sm text-crab-400 glow-red tracking-wider ml-1 transition-opacity duration-200 ${
|
||||
collapsed ? "opacity-0 absolute pointer-events-none" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
Sessions
|
||||
</h2>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className={`p-1.5 hover:bg-shell-800 rounded transition-all ${collapsed ? "mx-auto" : ""}`}
|
||||
className="p-1.5 hover:bg-shell-800 rounded transition-all"
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{collapsed ? (
|
||||
@@ -188,61 +188,68 @@ export function SessionList({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search input - hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter sessions..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="input-retro w-full pl-9 pr-3 py-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* Search input - fades when collapsed */}
|
||||
<div
|
||||
className={`transition-all duration-200 ${
|
||||
collapsed ? "opacity-0 h-0 overflow-hidden" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter sessions..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="input-retro w-full pl-9 pr-3 py-2 text-xs"
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Platform filters */}
|
||||
{platforms.length > 1 && (
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap">
|
||||
{/* Platform filters */}
|
||||
{platforms.length > 1 && (
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => setPlatformFilter(null)}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
!platformFilter
|
||||
? "bg-crab-600 border-crab-500 text-white box-glow-red"
|
||||
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{platforms.map((p) => (
|
||||
<button
|
||||
onClick={() => setPlatformFilter(null)}
|
||||
key={p}
|
||||
onClick={() => setPlatformFilter(p)}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
!platformFilter
|
||||
platformFilter === p
|
||||
? "bg-crab-600 border-crab-500 text-white box-glow-red"
|
||||
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
{platformEmoji[p] || "📱"} {p}
|
||||
</button>
|
||||
{platforms.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPlatformFilter(p)}
|
||||
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
platformFilter === p
|
||||
? "bg-crab-600 border-crab-500 text-white box-glow-red"
|
||||
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{platformEmoji[p] || "📱"} {p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="relative flex-1 overflow-y-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<AnimatePresence mode="sync">
|
||||
{sortedParents.map((session) => (
|
||||
<div key={session.key}>
|
||||
<motion.button
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
<motion.div
|
||||
key={session.key}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left p-3 border-b border-shell-800 transition-all duration-150 group ${
|
||||
selectedKey === session.key
|
||||
@@ -290,7 +297,7 @@ export function SessionList({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</button>
|
||||
|
||||
{/* Nested subagents */}
|
||||
{(() => {
|
||||
@@ -314,12 +321,12 @@ export function SessionList({
|
||||
} flex items-center gap-1.5 text-xs font-display uppercase tracking-widest text-shell-500 hover:text-shell-300 hover:bg-shell-800/30`}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform ${isGroupCollapsed ? "-rotate-90" : ""}`}
|
||||
size={16}
|
||||
className={`transition-transform ${isGroupCollapsed ? "-rotate-90" : ""} ${collapsed ? "ml-2" : ""}`}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span>{subs.length} subagent{subs.length > 1 ? "s" : ""}</span>
|
||||
)}
|
||||
<span className={`transition-opacity duration-200 ${collapsed ? "opacity-0 w-0 overflow-hidden" : "opacity-100"}`}>
|
||||
{subs.length} subagent{subs.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<motion.div
|
||||
initial={false}
|
||||
@@ -343,23 +350,30 @@ export function SessionList({
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Orphan subagents */}
|
||||
{orphanSubagents.map((sub) => (
|
||||
<SubagentItem
|
||||
<motion.div
|
||||
key={sub.key}
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
collapsed={collapsed}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<SubagentItem
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
collapsed={collapsed}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{sortedParents.length === 0 && orphanSubagents.length === 0 && !collapsed && (
|
||||
<div className="p-6 text-center">
|
||||
{sortedParents.length === 0 && orphanSubagents.length === 0 && (
|
||||
<div className={`p-6 text-center transition-opacity duration-200 ${collapsed ? "opacity-0" : "opacity-100"}`}>
|
||||
<div className="font-console text-xs text-shell-500">
|
||||
<span className="text-crab-600">></span> no sessions found
|
||||
</div>
|
||||
@@ -368,10 +382,8 @@ export function SessionList({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`relative bg-shell-950/50 ${collapsed ? "py-4 px-2" : "p-2.5"}`}>
|
||||
<div
|
||||
className={`font-console text-xs text-shell-500 text-center flex items-center justify-center ${collapsed ? "flex-col gap-3" : "gap-4"}`}
|
||||
>
|
||||
<div className={`relative bg-shell-950/50 ${collapsed ? "py-3 px-2" : "p-2.5"}`}>
|
||||
<div className={`font-console text-xs text-shell-500 text-center flex items-center justify-center ${collapsed ? "flex-col gap-3" : "gap-4"}`}>
|
||||
<a
|
||||
href="https://github.com/luccast/crabwalk"
|
||||
target="_blank"
|
||||
@@ -380,7 +392,9 @@ export function SessionList({
|
||||
title="Github"
|
||||
>
|
||||
<Github size={14} />
|
||||
{!collapsed && <span>Github</span>}
|
||||
<span className={`transition-opacity duration-200 ${collapsed ? "hidden" : "opacity-100"}`}>
|
||||
Github
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
@@ -392,7 +406,9 @@ export function SessionList({
|
||||
title="X"
|
||||
>
|
||||
<XIcon size={14} />
|
||||
{!collapsed && <span>@luccasveg</span>}
|
||||
<span className={`transition-opacity duration-200 ${collapsed ? "hidden" : "opacity-100"}`}>
|
||||
@luccasveg
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,9 +54,10 @@ export function SettingsPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger button - hidden on mobile, settings available in bottom bar */}
|
||||
<button
|
||||
onClick={() => onOpenChange(true)}
|
||||
className="p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
|
||||
className="hidden sm:block p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
|
||||
>
|
||||
<Settings size={14} className="text-gray-400 group-hover:text-crab-400 transition-colors" />
|
||||
</button>
|
||||
|
||||
@@ -6,3 +6,5 @@ export { ExecNode } from './ExecNode'
|
||||
export { CrabNode } from './CrabNode'
|
||||
export { StatusIndicator } from './StatusIndicator'
|
||||
export { SettingsPanel } from './SettingsPanel'
|
||||
export { MobileSessionDrawer } from './MobileSessionDrawer'
|
||||
export { MobileMonitorToolbar } from './MobileMonitorToolbar'
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Activity, FolderTree, ChevronDown, Terminal } from 'lucide-react'
|
||||
|
||||
interface NavTab {
|
||||
path: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const TABS: NavTab[] = [
|
||||
{
|
||||
path: '/monitor',
|
||||
label: 'MONITOR',
|
||||
icon: <Activity size={14} />,
|
||||
},
|
||||
{
|
||||
path: '/workspace',
|
||||
label: 'WORKSPACE',
|
||||
icon: <FolderTree size={14} />,
|
||||
},
|
||||
]
|
||||
|
||||
export function NavTabs() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const currentPath = location.pathname.replace(/\/$/, '') || '/'
|
||||
|
||||
const activeTab = (TABS.find(
|
||||
(tab) => tab.path === currentPath || currentPath.startsWith(tab.path)
|
||||
) ?? TABS[0])!
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (path: string) => {
|
||||
setOpen(false)
|
||||
navigate({ to: path })
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-shell-700/50 bg-shell-800/50 hover:bg-shell-800 hover:border-shell-600 transition-all group"
|
||||
>
|
||||
<span className="text-crab-400">{activeTab.icon}</span>
|
||||
<span className="font-console text-xs tracking-widest text-shell-200">
|
||||
{activeTab.label}
|
||||
</span>
|
||||
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`text-shell-500 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 400 }}
|
||||
className="absolute top-full left-0 mt-2 z-50 min-w-[200px]"
|
||||
>
|
||||
{/* Terminal-style container */}
|
||||
<div className="bg-shell-900 border border-shell-700 rounded-lg overflow-hidden shadow-2xl shadow-black/50">
|
||||
{/* Terminal header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-shell-950 border-b border-shell-800">
|
||||
<Terminal size={12} className="text-shell-500" />
|
||||
<span className="font-console text-[11px] text-shell-500 uppercase tracking-widest">
|
||||
navigate
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="p-1.5">
|
||||
{TABS.map((tab, index) => {
|
||||
const isActive =
|
||||
tab.path === currentPath || currentPath.startsWith(tab.path)
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={tab.path}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onClick={() => handleSelect(tab.path)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md transition-all group ${
|
||||
isActive
|
||||
? 'bg-crab-500/10 text-crab-400'
|
||||
: 'text-shell-400 hover:bg-shell-800 hover:text-shell-200'
|
||||
}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span
|
||||
className={`transition-colors ${
|
||||
isActive ? 'text-crab-400' : 'text-shell-500 group-hover:text-shell-400'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
<span className="font-console text-xs tracking-widest flex-1 text-left">
|
||||
{tab.label}
|
||||
</span>
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="nav-dropdown-active"
|
||||
className="w-1.5 h-1.5 rounded-full bg-crab-500"
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Terminal footer with hint */}
|
||||
<div className="px-3 pb-2 pt-1 bg-shell-950/50 border-t border-shell-800/50">
|
||||
<span className="font-console text-[11px] text-shell-600">
|
||||
<span className="text-shell-500">esc</span> to close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { NavTabs } from './NavTabs'
|
||||
@@ -90,7 +90,7 @@ function FileTreeItem({ entry, selectedPath, onSelect, onLoadDirectory, level }:
|
||||
<motion.div
|
||||
onClick={handleClick}
|
||||
style={{ paddingLeft }}
|
||||
className={`w-full flex items-center gap-2 py-1.5 pr-3 text-left transition-all duration-150 rounded-md mx-1 cursor-pointer ${
|
||||
className={`flex items-center gap-2 py-1.5 pr-2 text-left transition-all duration-150 rounded-md mr-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400 border-l-2 border-crab-400'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100 border-l-2 border-transparent'
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { FileText, AlertCircle } from 'lucide-react'
|
||||
import { FileText, AlertCircle, Star } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string
|
||||
fileName: string
|
||||
filePath?: string
|
||||
fileSize?: number
|
||||
fileModified?: Date
|
||||
error?: string
|
||||
isStarred?: boolean
|
||||
onStar?: (path: string) => void
|
||||
}
|
||||
|
||||
// Format file size to human-readable format
|
||||
@@ -45,7 +48,7 @@ function formatModifiedDate(date: Date | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, fileName, fileSize, fileModified, error }: MarkdownViewerProps) {
|
||||
export function MarkdownViewer({ content, fileName, filePath, fileSize, fileModified, error, isStarred, onStar }: MarkdownViewerProps) {
|
||||
const isMarkdown = useMemo(() => {
|
||||
return fileName.toLowerCase().endsWith('.md') || fileName.toLowerCase().endsWith('.markdown')
|
||||
}, [fileName])
|
||||
@@ -96,6 +99,20 @@ export function MarkdownViewer({ content, fileName, fileSize, fileModified, erro
|
||||
<div className="h-full flex flex-col">
|
||||
{/* File header */}
|
||||
<div className="flex items-center gap-3 px-6 py-4 border-b border-shell-800 bg-shell-900/50">
|
||||
{/* Star button */}
|
||||
{filePath && onStar && (
|
||||
<button
|
||||
onClick={() => onStar(filePath)}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isStarred
|
||||
? 'text-yellow-400 hover:text-yellow-300'
|
||||
: 'text-shell-600 hover:text-yellow-400'
|
||||
}`}
|
||||
title={isStarred ? 'Unstar file' : 'Star file'}
|
||||
>
|
||||
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<FileText size={18} className={isMarkdown ? 'text-crab-400' : 'text-shell-500'} />
|
||||
<h2 className="font-display text-sm text-gray-200">{fileName}</h2>
|
||||
{isMarkdown && (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { PanelLeft, FolderOpen, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface MobileBottomToolbarProps {
|
||||
onOpenDrawer: () => void
|
||||
onOpenPathSheet: () => void
|
||||
onRefresh: () => void
|
||||
loading: boolean
|
||||
pathValid: boolean
|
||||
currentPath: string
|
||||
}
|
||||
|
||||
export function MobileBottomToolbar({
|
||||
onOpenDrawer,
|
||||
onOpenPathSheet,
|
||||
onRefresh,
|
||||
loading,
|
||||
pathValid,
|
||||
currentPath,
|
||||
}: MobileBottomToolbarProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="fixed bottom-0 left-0 right-0 z-40 sm:hidden"
|
||||
>
|
||||
<div className="bg-shell-900 border-t border-shell-800 px-3 pt-3 pb-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Files button */}
|
||||
<button
|
||||
onClick={onOpenDrawer}
|
||||
className="p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors"
|
||||
>
|
||||
<PanelLeft size={22} />
|
||||
</button>
|
||||
|
||||
{/* Path input field */}
|
||||
<button
|
||||
onClick={onOpenPathSheet}
|
||||
className="flex-1 flex items-center gap-2 px-3 py-2.5 bg-shell-800 border border-shell-700 rounded-lg active:border-crab-500 transition-colors min-h-[44px]"
|
||||
>
|
||||
<FolderOpen size={16} className={pathValid ? 'text-crab-400 shrink-0' : 'text-shell-500 shrink-0'} />
|
||||
<span className={`font-console text-sm truncate text-left ${currentPath ? 'text-gray-200' : 'text-shell-500'}`}>
|
||||
{currentPath || 'Set workspace path...'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={!pathValid || loading}
|
||||
className="p-3 min-w-[48px] min-h-[48px] rounded-lg active:bg-shell-800 text-gray-400 active:text-crab-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw size={22} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, FileText, Star } from 'lucide-react'
|
||||
import { FileTree } from './FileTree'
|
||||
import type { DirectoryEntry } from '~/lib/workspace-fs'
|
||||
|
||||
interface MobileFileDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
entries: DirectoryEntry[]
|
||||
selectedPath: string | null
|
||||
starredPaths: Set<string>
|
||||
workspacePath: string
|
||||
pathValid: boolean
|
||||
onSelect: (path: string, type: 'file' | 'directory') => void
|
||||
onLoadDirectory: (path: string) => Promise<DirectoryEntry[]>
|
||||
onStar: (path: string) => void
|
||||
}
|
||||
|
||||
export function MobileFileDrawer({
|
||||
open,
|
||||
onClose,
|
||||
entries,
|
||||
selectedPath,
|
||||
starredPaths,
|
||||
workspacePath,
|
||||
pathValid,
|
||||
onSelect,
|
||||
onLoadDirectory,
|
||||
onStar,
|
||||
}: MobileFileDrawerProps) {
|
||||
// Handle file select with auto-close
|
||||
const handleSelect = (path: string, type: 'file' | 'directory') => {
|
||||
onSelect(path, type)
|
||||
if (type === 'file') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed inset-y-0 left-0 w-full max-w-[85vw] bg-shell-900 z-50 flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-shell-800">
|
||||
<span className="font-display text-sm text-crab-400 uppercase tracking-wider">
|
||||
Files
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 -mr-2 hover:bg-shell-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={24} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Starred files section */}
|
||||
{starredPaths.size > 0 && (
|
||||
<div className="border-b border-shell-800 py-2">
|
||||
{[...starredPaths].map((filePath) => {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const ext = fileName.includes('.') ? '.' + fileName.split('.').pop() : ''
|
||||
const isSelected = selectedPath === filePath
|
||||
return (
|
||||
<div
|
||||
key={filePath}
|
||||
className={`group flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400'
|
||||
: 'text-gray-300 active:bg-shell-800'
|
||||
}`}
|
||||
onClick={() => handleSelect(filePath, 'file')}
|
||||
>
|
||||
<FileText
|
||||
size={18}
|
||||
className={`shrink-0 ${
|
||||
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-console text-sm truncate flex-1">
|
||||
{fileName}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onStar(filePath)
|
||||
}}
|
||||
className="text-yellow-400 active:text-yellow-300 shrink-0 p-1"
|
||||
>
|
||||
<Star size={16} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File tree */}
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
entries={entries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={onLoadDirectory}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="font-console text-xs text-shell-500">
|
||||
Set a workspace path to browse files
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with path */}
|
||||
{pathValid && (
|
||||
<div className="px-4 py-3 border-t border-shell-800 bg-shell-950/50">
|
||||
<p className="font-console text-[10px] text-shell-600 truncate">
|
||||
{workspacePath}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { FolderOpen, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface MobilePathSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
initialPath: string
|
||||
validatedPath: string
|
||||
pathValid: boolean
|
||||
pathError: string | null
|
||||
onValidate: (path: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function MobilePathSheet({
|
||||
open,
|
||||
onClose,
|
||||
initialPath,
|
||||
validatedPath,
|
||||
pathValid,
|
||||
pathError,
|
||||
onValidate,
|
||||
}: MobilePathSheetProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [pathInput, setPathInput] = useState(initialPath)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Sync initial path when it changes
|
||||
useEffect(() => {
|
||||
setPathInput(initialPath)
|
||||
}, [initialPath])
|
||||
|
||||
// Auto-focus input after animation
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!pathInput.trim() || loading) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const success = await onValidate(pathInput)
|
||||
if (success) {
|
||||
onClose()
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 bg-shell-900 rounded-t-2xl"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div className="w-10 h-1 bg-shell-700 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 pb-safe">
|
||||
<h3 className="font-display text-sm text-crab-400 uppercase tracking-wider mb-4">
|
||||
Workspace Path
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FolderOpen size={18} className="text-shell-500 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter workspace path..."
|
||||
className="flex-1 bg-shell-800 border border-shell-700 rounded-lg px-4 py-3 text-base font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pathError && (
|
||||
<div className="mb-4 px-3 py-2 bg-crab-900/50 border border-crab-700 rounded-lg flex items-center gap-2">
|
||||
<AlertCircle size={16} className="text-crab-400 shrink-0" />
|
||||
<span className="text-xs text-crab-200 font-console">{pathError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !pathInput.trim() || (pathValid && pathInput === validatedPath)}
|
||||
className={`w-full py-4 font-display text-sm uppercase tracking-wider rounded-lg transition-colors mb-4 ${
|
||||
pathValid && pathInput === validatedPath
|
||||
? 'bg-shell-800 text-shell-500 cursor-default'
|
||||
: 'bg-crab-600 hover:bg-crab-500 active:bg-crab-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{loading ? 'Opening...' : 'Open'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
export { FileTree } from './FileTree'
|
||||
export { MarkdownViewer } from './MarkdownViewer'
|
||||
export { MobileBottomToolbar } from './MobileBottomToolbar'
|
||||
export { MobileFileDrawer } from './MobileFileDrawer'
|
||||
export { MobilePathSheet } from './MobilePathSheet'
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useIsMobile(breakpoint = 640) {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
|
||||
setIsMobile(mql.matches)
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mql.addEventListener('change', handler)
|
||||
|
||||
return () => mql.removeEventListener('change', handler)
|
||||
}, [breakpoint])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useLiveQuery } from '@tanstack/react-db'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowLeft, Loader2, HardDrive, Trash2 } from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import { NavTabs } from '~/components/navigation'
|
||||
import {
|
||||
sessionsCollection,
|
||||
actionsCollection,
|
||||
@@ -21,8 +22,11 @@ import {
|
||||
SessionList,
|
||||
SettingsPanel,
|
||||
StatusIndicator,
|
||||
MobileSessionDrawer,
|
||||
MobileMonitorToolbar,
|
||||
} from '~/components/monitor'
|
||||
import { CrabIdleAnimation } from '~/components/ani'
|
||||
import { useIsMobile } from '~/hooks/useIsMobile'
|
||||
|
||||
export const Route = createFileRoute('/monitor/')({
|
||||
component: MonitorPageWrapper,
|
||||
@@ -83,6 +87,10 @@ function MonitorPage() {
|
||||
// Settings panel state
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
// Mobile state
|
||||
const isMobile = useIsMobile()
|
||||
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false)
|
||||
|
||||
// Live queries from TanStack DB collections
|
||||
const sessionsQuery = useLiveQuery(sessionsCollection)
|
||||
const actionsQuery = useLiveQuery(actionsCollection)
|
||||
@@ -379,27 +387,11 @@ function MonitorPage() {
|
||||
</Link>
|
||||
|
||||
{/* Navigation tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Monitor tab - active */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-crab-900/30 border border-crab-700/30">
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
|
||||
MONITOR
|
||||
</span>
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
|
||||
</div>
|
||||
<NavTabs />
|
||||
|
||||
{/* Workspace tab - inactive */}
|
||||
<Link
|
||||
to="/workspace"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-shell-800 transition-all border border-transparent hover:border-shell-600"
|
||||
>
|
||||
<span className="font-arcade text-xs text-gray-500 tracking-wider">
|
||||
WORKSPACE
|
||||
</span>
|
||||
</Link>
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<StatusIndicator status={connecting ? 'thinking' : connected ? 'active' : 'idle'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -495,17 +487,19 @@ function MonitorPage() {
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
selectedKey={selectedSession}
|
||||
onSelect={setSelectedSession}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={handleToggleSidebar}
|
||||
/>
|
||||
{/* Sidebar - desktop only */}
|
||||
{!isMobile && (
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
selectedKey={selectedSession}
|
||||
onSelect={setSelectedSession}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={handleToggleSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Graph area */}
|
||||
<div className="flex-1 relative">
|
||||
<div className={`flex-1 relative ${isMobile ? 'pb-20' : ''}`}>
|
||||
<ActionGraph
|
||||
sessions={sessions}
|
||||
actions={actions}
|
||||
@@ -515,6 +509,29 @@ function MonitorPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile components */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<MobileMonitorToolbar
|
||||
onOpenDrawer={() => setSessionDrawerOpen(true)}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
connected={connected}
|
||||
connecting={connecting}
|
||||
sessionCount={sessions.length}
|
||||
actionCount={actions.length}
|
||||
completedCount={completedCount}
|
||||
onClearCompleted={handleClearCompleted}
|
||||
/>
|
||||
<MobileSessionDrawer
|
||||
open={sessionDrawerOpen}
|
||||
onClose={() => setSessionDrawerOpen(false)}
|
||||
sessions={sessions}
|
||||
selectedKey={selectedSession}
|
||||
onSelect={setSelectedSession}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+247
-106
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
@@ -8,10 +8,20 @@ import {
|
||||
AlertCircle,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
Star,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '~/integrations/trpc/client'
|
||||
import { FileTree, MarkdownViewer } from '~/components/workspace'
|
||||
import {
|
||||
FileTree,
|
||||
MarkdownViewer,
|
||||
MobileBottomToolbar,
|
||||
MobileFileDrawer,
|
||||
MobilePathSheet,
|
||||
} from '~/components/workspace'
|
||||
import { NavTabs } from '~/components/navigation'
|
||||
import { CrabIdleAnimation } from '~/components/ani'
|
||||
import { useIsMobile } from '~/hooks/useIsMobile'
|
||||
import type { DirectoryEntry } from '~/lib/workspace-fs'
|
||||
|
||||
// Get parent directory path using path separator logic
|
||||
@@ -82,10 +92,18 @@ function WorkspacePage() {
|
||||
// Sidebar collapse state
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
// Starred files state
|
||||
const [starredPaths, setStarredPaths] = useState<Set<string>>(new Set())
|
||||
|
||||
// Mobile state
|
||||
const isMobile = useIsMobile()
|
||||
const [fileDrawerOpen, setFileDrawerOpen] = useState(false)
|
||||
const [pathSheetOpen, setPathSheetOpen] = useState(false)
|
||||
|
||||
// Root entries for FileTree
|
||||
const rootEntries = workspacePath && pathValid ? (pathCache.get(workspacePath) || []) : []
|
||||
|
||||
// Load saved path or default on mount
|
||||
// Load saved path and starred files on mount
|
||||
useEffect(() => {
|
||||
const savedPath = localStorage.getItem('crabcrawl:workspacePath')
|
||||
if (savedPath) {
|
||||
@@ -95,6 +113,17 @@ function WorkspacePage() {
|
||||
} else {
|
||||
loadDefaultPath()
|
||||
}
|
||||
|
||||
// Load starred files
|
||||
const savedStarred = localStorage.getItem('crabcrawl:starredFiles')
|
||||
if (savedStarred) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedStarred)
|
||||
setStarredPaths(new Set(parsed))
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load entries when workspace path changes and is valid
|
||||
@@ -312,6 +341,21 @@ function WorkspacePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle starring/unstarring files
|
||||
const handleStar = useCallback((filePath: string) => {
|
||||
setStarredPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath)
|
||||
} else {
|
||||
next.add(filePath)
|
||||
}
|
||||
// Persist to localStorage
|
||||
localStorage.setItem('crabcrawl:starredFiles', JSON.stringify([...next]))
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
return (
|
||||
@@ -330,48 +374,33 @@ function WorkspacePage() {
|
||||
</Link>
|
||||
|
||||
{/* Navigation tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Monitor tab - inactive */}
|
||||
<Link
|
||||
to="/monitor"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-shell-800 transition-all border border-transparent hover:border-shell-600"
|
||||
>
|
||||
<span className="font-arcade text-xs text-gray-500 tracking-wider">
|
||||
MONITOR
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Workspace tab - active */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-crab-900/30 border border-crab-700/30">
|
||||
<div className="crab-icon-glow">
|
||||
<CrabIdleAnimation className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="font-arcade text-xs text-crab-400 glow-red tracking-wider">
|
||||
WORKSPACE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<NavTabs />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3 flex-1 max-w-2xl mx-4">
|
||||
{/* Path input */}
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<FolderOpen size={16} className="text-shell-500 flex-shrink-0" />
|
||||
{/* Path input - desktop only */}
|
||||
<div className="hidden sm:flex relative items-center gap-2 flex-1 max-w-2xl mx-4">
|
||||
<div className="flex-1 relative">
|
||||
<FolderOpen size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-shell-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={workspacePathInput}
|
||||
onChange={(e) => setWorkspacePathInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter workspace path..."
|
||||
className="flex-1 bg-shell-800 border border-shell-700 rounded-lg px-3 py-1.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20"
|
||||
className="w-full bg-shell-800 border border-shell-700 rounded-lg pl-9 pr-3 py-1.5 text-sm font-console text-gray-200 placeholder-shell-500 focus:outline-none focus:border-crab-500 focus:ring-1 focus:ring-crab-500/20"
|
||||
/>
|
||||
<button
|
||||
onClick={validateAndSetPath}
|
||||
className="px-3 py-1.5 bg-crab-600 hover:bg-crab-500 text-white text-sm font-display rounded-lg transition-colors"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={validateAndSetPath}
|
||||
disabled={pathValid && workspacePathInput === workspacePath}
|
||||
className={`px-3 py-1.5 text-sm font-display rounded-lg transition-colors shrink-0 ${
|
||||
pathValid && workspacePathInput === workspacePath
|
||||
? 'bg-shell-800 text-shell-500 cursor-default'
|
||||
: 'bg-crab-600 hover:bg-crab-500 text-white'
|
||||
}`}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
|
||||
{pathError && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 px-3 py-2 bg-crab-900/90 border border-crab-700 rounded-lg flex items-center gap-2 z-50">
|
||||
@@ -381,8 +410,8 @@ function WorkspacePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3">
|
||||
{/* Refresh button */}
|
||||
{/* Refresh button - desktop only */}
|
||||
<div className="hidden sm:flex relative items-center gap-3">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={!pathValid || loading}
|
||||
@@ -399,93 +428,205 @@ function WorkspacePage() {
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<AnimatePresence initial={false}>
|
||||
{!sidebarCollapsed && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 320, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="border-r border-shell-800 bg-shell-900/50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Sidebar header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-shell-800">
|
||||
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
|
||||
Files
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<RefreshCw size={14} className="text-shell-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
className="p-1 hover:bg-shell-800 rounded transition-colors"
|
||||
title="Hide sidebar"
|
||||
>
|
||||
<PanelLeftClose size={14} className="text-shell-500 hover:text-crab-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File tree */}
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
/>
|
||||
{/* Sidebar - desktop only */}
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: sidebarCollapsed ? 56 : 320 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="border-r border-shell-800 bg-shell-900/50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Sidebar header */}
|
||||
<div className={`flex items-center justify-between px-3 py-3 border-b border-shell-800 ${sidebarCollapsed ? 'justify-center' : ''}`}>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="font-display text-xs text-shell-500 uppercase tracking-wider">
|
||||
Files
|
||||
</span>
|
||||
)}
|
||||
<div className={`flex items-center gap-2 ${sidebarCollapsed ? 'mx-auto' : ''}`}>
|
||||
{loading && !sidebarCollapsed && (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<RefreshCw size={14} className="text-shell-500" />
|
||||
</motion.div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="p-1.5 hover:bg-shell-800 rounded transition-colors"
|
||||
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeft size={16} className="text-gray-400 hover:text-crab-400" />
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="font-console text-xs text-shell-500">
|
||||
Enter a workspace path to browse files
|
||||
</p>
|
||||
</div>
|
||||
<PanelLeftClose size={16} className="text-shell-500 hover:text-crab-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar footer */}
|
||||
{pathValid && (
|
||||
<div className="px-4 py-2 border-t border-shell-800">
|
||||
<p className="font-console text-[10px] text-shell-600 truncate">
|
||||
{workspacePath}
|
||||
{/* Starred files section */}
|
||||
{starredPaths.size > 0 && (
|
||||
<>
|
||||
{sidebarCollapsed ? (
|
||||
// Collapsed: stacked file icons
|
||||
<div className="flex flex-col items-center gap-1 py-2 border-b border-shell-800">
|
||||
{[...starredPaths].slice(0, 5).map((filePath) => {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const isSelected = selectedPath === filePath
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
onClick={() => handleSelect(filePath, 'file')}
|
||||
className={`relative p-1.5 rounded transition-colors ${
|
||||
isSelected ? 'bg-crab-500/20' : 'hover:bg-shell-800'
|
||||
}`}
|
||||
title={fileName}
|
||||
>
|
||||
<FileText
|
||||
size={16}
|
||||
className={isSelected ? 'text-crab-400' : 'text-shell-500'}
|
||||
/>
|
||||
<Star
|
||||
size={8}
|
||||
fill="currentColor"
|
||||
className="absolute -top-0.5 -right-0.5 text-yellow-400"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{starredPaths.size > 5 && (
|
||||
<span className="text-[10px] text-shell-500">+{starredPaths.size - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Expanded: starred files list
|
||||
<div className="border-b border-shell-800 py-2">
|
||||
{[...starredPaths].map((filePath) => {
|
||||
const fileName = filePath.split('/').pop() || filePath
|
||||
const ext = fileName.includes('.') ? '.' + fileName.split('.').pop() : ''
|
||||
const isSelected = selectedPath === filePath
|
||||
return (
|
||||
<div
|
||||
key={filePath}
|
||||
className={`group flex items-center gap-2 px-4 py-1.5 cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-crab-500/20 text-crab-400'
|
||||
: 'text-gray-300 hover:bg-shell-800 hover:text-gray-100'
|
||||
}`}
|
||||
onClick={() => handleSelect(filePath, 'file')}
|
||||
>
|
||||
<FileText
|
||||
size={14}
|
||||
className={`flex-shrink-0 ${
|
||||
ext === '.md' ? 'text-crab-400' : 'text-shell-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="font-console text-sm truncate flex-1">
|
||||
{fileName}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleStar(filePath)
|
||||
}}
|
||||
className="text-yellow-400 hover:text-yellow-300 flex-shrink-0"
|
||||
title="Unstar file"
|
||||
>
|
||||
<Star size={14} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File tree */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden py-2">
|
||||
{pathValid ? (
|
||||
<FileTree
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center">
|
||||
<p className="font-console text-xs text-shell-500">
|
||||
Enter a workspace path to browse files
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Sidebar footer */}
|
||||
{pathValid && !sidebarCollapsed && (
|
||||
<div className="px-4 py-2 border-t border-shell-800">
|
||||
<p className="font-console text-[10px] text-shell-600 truncate">
|
||||
{workspacePath}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 relative bg-shell-950">
|
||||
{/* Floating sidebar toggle when collapsed */}
|
||||
{sidebarCollapsed && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={() => setSidebarCollapsed(false)}
|
||||
className="absolute left-4 top-4 z-10 p-2 bg-shell-800/80 hover:bg-shell-700 rounded-lg border border-shell-700 transition-all"
|
||||
title="Show sidebar"
|
||||
>
|
||||
<PanelLeft size={18} className="text-gray-400 hover:text-crab-400" />
|
||||
</motion.button>
|
||||
)}
|
||||
<div className={`flex-1 relative bg-shell-950 ${isMobile ? 'pb-20' : ''}`}>
|
||||
<MarkdownViewer
|
||||
content={selectedFileContent}
|
||||
fileName={selectedFileName}
|
||||
filePath={selectedPath ?? undefined}
|
||||
fileSize={selectedFileSize}
|
||||
fileModified={selectedFileModified}
|
||||
error={fileError}
|
||||
isStarred={selectedPath ? starredPaths.has(selectedPath) : false}
|
||||
onStar={handleStar}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile components */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<MobileBottomToolbar
|
||||
onOpenDrawer={() => setFileDrawerOpen(true)}
|
||||
onOpenPathSheet={() => setPathSheetOpen(true)}
|
||||
onRefresh={handleRefresh}
|
||||
loading={loading}
|
||||
pathValid={pathValid}
|
||||
currentPath={workspacePathInput}
|
||||
/>
|
||||
<MobileFileDrawer
|
||||
open={fileDrawerOpen}
|
||||
onClose={() => setFileDrawerOpen(false)}
|
||||
entries={rootEntries}
|
||||
selectedPath={selectedPath}
|
||||
starredPaths={starredPaths}
|
||||
workspacePath={workspacePath}
|
||||
pathValid={pathValid}
|
||||
onSelect={handleSelect}
|
||||
onLoadDirectory={handleLoadDirectory}
|
||||
onStar={handleStar}
|
||||
/>
|
||||
<MobilePathSheet
|
||||
open={pathSheetOpen}
|
||||
onClose={() => setPathSheetOpen(false)}
|
||||
initialPath={workspacePathInput}
|
||||
validatedPath={workspacePath}
|
||||
pathValid={pathValid}
|
||||
pathError={pathError}
|
||||
onValidate={async (path) => {
|
||||
await validatePathAndSet(path)
|
||||
return pathValid
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -433,3 +433,8 @@ code, pre {
|
||||
@apply focus:outline-none focus:border-crab-500 focus:ring-2 focus:ring-crab-500/20;
|
||||
@apply transition-all duration-150;
|
||||
}
|
||||
|
||||
/* Safe area padding for iOS */
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user