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:
Luciano Castillo
2026-02-02 11:04:09 -05:00
committed by GitHub
parent 5636167c57
commit 34ef562bc1
17 changed files with 1407 additions and 214 deletions
@@ -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">&gt;</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>
)
}
+91 -75
View File
@@ -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">&gt;</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>
+2 -1
View File
@@ -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>
+2
View File
@@ -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'
+173
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export { NavTabs } from './NavTabs'
+1 -1
View File
@@ -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'
+19 -2
View File
@@ -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>
)
}
+3
View File
@@ -1,2 +1,5 @@
export { FileTree } from './FileTree'
export { MarkdownViewer } from './MarkdownViewer'
export { MobileBottomToolbar } from './MobileBottomToolbar'
export { MobileFileDrawer } from './MobileFileDrawer'
export { MobilePathSheet } from './MobilePathSheet'
+17
View File
@@ -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
}
+46 -29
View File
@@ -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
View File
@@ -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>
)
}
+5
View File
@@ -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);
}