Mobile-responsive: task cards, detail panel, sticky header

- TaskCard: hide action buttons on mobile (tap to open detail panel), smaller text/badges, word-break titles
- TaskDetailPanel: full-screen on mobile with Back button, responsive padding, stacked timeline, hidden UUID on small screens
- QueuePage: sticky header offset for mobile nav bar (top-14)
- Priority/source grid stacks vertically on mobile
This commit is contained in:
2026-01-29 04:32:46 +00:00
parent 46ada23bcb
commit 8685548206
3 changed files with 85 additions and 72 deletions

View File

@@ -72,29 +72,30 @@ export function TaskCard({
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className={`rounded-xl border p-4 transition-all cursor-pointer group ${ className={`rounded-xl border p-3 sm:p-4 transition-all cursor-pointer group ${
isActive isActive
? "border-amber-300 bg-gradient-to-r from-amber-50 to-orange-50 shadow-lg shadow-amber-100/50 hover:shadow-xl hover:shadow-amber-200/50" ? "border-amber-300 bg-gradient-to-r from-amber-50 to-orange-50 shadow-lg shadow-amber-100/50 hover:shadow-xl hover:shadow-amber-200/50"
: "border-gray-200 bg-white shadow-sm hover:shadow-md hover:border-gray-300" : "border-gray-200 bg-white shadow-sm hover:shadow-md hover:border-gray-300"
}`} }`}
> >
<div className="flex items-start justify-between gap-3"> {/* Top row: title + expand chevron */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1.5"> <div className="flex items-center gap-2 flex-wrap mb-1.5">
{isActive && ( {isActive && (
<span className="relative flex h-2.5 w-2.5 mr-0.5"> <span className="relative flex h-2.5 w-2.5 mr-0.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span> <span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
</span> </span>
)} )}
<h3 className={`font-semibold truncate ${isActive ? "text-amber-900" : "text-gray-900"}`}> <h3 className={`font-semibold text-sm sm:text-base leading-snug ${isActive ? "text-amber-900" : "text-gray-900"}`} style={{ wordBreak: "break-word" }}>
{task.title} {task.title}
</h3> </h3>
</div> </div>
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-1.5 sm:gap-2 mb-1.5 flex-wrap">
<span <span
className="text-xs px-1.5 py-0.5 rounded font-mono font-bold bg-amber-100 text-amber-700 cursor-pointer hover:bg-amber-200 transition" className="text-[10px] sm:text-xs px-1.5 py-0.5 rounded font-mono font-bold bg-amber-100 text-amber-700 cursor-pointer hover:bg-amber-200 transition"
title={`Click to copy: ${displayId}`} title={`Click to copy: ${displayId}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -103,68 +104,69 @@ export function TaskCard({
> >
{displayId} {displayId}
</span> </span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}> <span className={`text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
{task.priority} {task.priority}
</span> </span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source] || sourceColors.other}`}> <span className={`text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source] || sourceColors.other}`}>
{task.source} {task.source}
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-[10px] sm:text-xs text-gray-400">
{timeAgo(task.createdAt)} {timeAgo(task.createdAt)}
</span> </span>
{noteCount > 0 && ( {noteCount > 0 && (
<span className="text-xs text-gray-400 flex items-center gap-0.5"> <span className="text-[10px] sm:text-xs text-gray-400 flex items-center gap-0.5">
💬 {noteCount} 💬 {noteCount}
</span> </span>
)} )}
</div> </div>
{task.description && ( {task.description && (
<p className="text-sm text-gray-500 line-clamp-1">{task.description}</p> <p className="text-xs sm:text-sm text-gray-500 line-clamp-1">{task.description}</p>
)} )}
</div> </div>
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}> {/* Expand chevron - always visible */}
{/* Reorder buttons for queued tasks */} <div className="shrink-0 mt-1 text-gray-300 group-hover:text-gray-500 transition">
{task.status === "queued" && ( <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<div className="flex gap-1 mr-1"> <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
<button </svg>
onClick={onMoveUp} </div>
disabled={isFirst} </div>
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
title="Move up"
>
</button>
<button
onClick={onMoveDown}
disabled={isLast}
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
title="Move down"
>
</button>
</div>
)}
{/* Quick status actions - show fewer on card, full set in detail */} {/* Action buttons row - hidden on mobile, shown on sm+ */}
{actions.slice(0, 2).map((action) => ( <div className="hidden sm:flex items-center gap-1 mt-2 pt-2 border-t border-gray-100" onClick={(e) => e.stopPropagation()}>
{/* Reorder buttons for queued tasks */}
{task.status === "queued" && (
<div className="flex gap-1 mr-1">
<button <button
key={action.next} onClick={onMoveUp}
onClick={() => onStatusChange(task.id, action.next)} disabled={isFirst}
className="text-xs px-2.5 py-1.5 rounded-lg border border-gray-200 hover:bg-gray-50 whitespace-nowrap text-gray-600 hover:text-gray-800 transition font-medium" className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
title="Move up"
> >
{action.label}
</button>
<button
onClick={onMoveDown}
disabled={isLast}
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
title="Move down"
>
</button> </button>
))}
{/* Expand indicator */}
<div className="ml-1 text-gray-300 group-hover:text-gray-500 transition">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div> </div>
</div> )}
{/* Quick status actions */}
{actions.slice(0, 2).map((action) => (
<button
key={action.next}
onClick={() => onStatusChange(task.id, action.next)}
className="text-xs px-2.5 py-1.5 rounded-lg border border-gray-200 hover:bg-gray-50 whitespace-nowrap text-gray-600 hover:text-gray-800 transition font-medium"
>
{action.label}
</button>
))}
</div> </div>
</div> </div>
); );

View File

@@ -198,13 +198,13 @@ function CopyableId({ id, taskNumber }: { id: string; taskNumber?: number }) {
}; };
return ( return (
<div className="px-6 py-3 border-t border-gray-100 bg-gray-50 flex items-center gap-2"> <div className="px-4 sm:px-6 py-3 border-t border-gray-100 bg-gray-50 flex items-center gap-2">
{displayId && ( {displayId && (
<> <>
<span className="text-xs font-bold text-amber-700 bg-amber-100 px-2 py-0.5 rounded font-mono">{displayId}</span> <span className="text-xs font-bold text-amber-700 bg-amber-100 px-2 py-0.5 rounded font-mono">{displayId}</span>
<button <button
onClick={() => handleCopy(displayId, "ref")} onClick={() => handleCopy(displayId, "ref")}
className={`text-xs px-2 py-0.5 rounded-md border transition font-medium ${ className={`text-xs px-2 py-0.5 rounded-md border transition font-medium shrink-0 ${
copied === "ref" copied === "ref"
? "bg-green-50 text-green-600 border-green-200" ? "bg-green-50 text-green-600 border-green-200"
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-100" : "bg-white text-gray-500 border-gray-200 hover:bg-gray-100"
@@ -212,13 +212,13 @@ function CopyableId({ id, taskNumber }: { id: string; taskNumber?: number }) {
> >
{copied === "ref" ? "✓" : "📋"} {copied === "ref" ? "✓" : "📋"}
</button> </button>
<span className="text-gray-300">|</span> <span className="text-gray-300 hidden sm:inline">|</span>
</> </>
)} )}
<code className="text-xs text-gray-400 font-mono flex-1 truncate select-all">{id}</code> <code className="text-[10px] sm:text-xs text-gray-400 font-mono flex-1 truncate select-all hidden sm:block">{id}</code>
<button <button
onClick={() => handleCopy(id, "uuid")} onClick={() => handleCopy(id, "uuid")}
className={`text-xs px-2.5 py-1 rounded-md border transition font-medium ${ className={`text-xs px-2.5 py-1 rounded-md border transition font-medium shrink-0 ${
copied === "uuid" copied === "uuid"
? "bg-green-50 text-green-600 border-green-200" ? "bg-green-50 text-green-600 border-green-200"
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-100 hover:text-gray-700" : "bg-white text-gray-500 border-gray-200 hover:bg-gray-100 hover:text-gray-700"
@@ -315,11 +315,21 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
/> />
{/* Panel */} {/* Panel */}
<div className="fixed inset-y-0 right-0 w-full max-w-lg bg-white shadow-2xl z-50 flex flex-col animate-slide-in-right"> <div className="fixed inset-0 sm:inset-y-0 sm:left-auto sm:right-0 w-full sm:max-w-lg bg-white shadow-2xl z-50 flex flex-col animate-slide-in-right">
{/* Header */} {/* Header */}
<div className={`px-6 py-4 border-b ${isActive ? "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" : "bg-gray-50 border-gray-200"}`}> <div className={`px-4 sm:px-6 py-3 sm:py-4 border-b ${isActive ? "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" : "bg-gray-50 border-gray-200"}`}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-2 sm:gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Back button on mobile */}
<button
onClick={onClose}
className="sm:hidden flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-2 -ml-1 px-1 py-0.5 rounded transition"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back
</button>
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="flex items-center gap-2 mb-2 flex-wrap">
{isActive && ( {isActive && (
<span className="relative flex h-3 w-3"> <span className="relative flex h-3 w-3">
@@ -341,15 +351,16 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
<EditableText <EditableText
value={draftTitle} value={draftTitle}
onChange={setDraftTitle} onChange={setDraftTitle}
className="text-lg font-bold text-gray-900 leading-snug" className="text-base sm:text-lg font-bold text-gray-900 leading-snug"
/> />
) : ( ) : (
<h2 className="text-lg font-bold text-gray-900 leading-snug">{task.title}</h2> <h2 className="text-base sm:text-lg font-bold text-gray-900 leading-snug">{task.title}</h2>
)} )}
</div> </div>
{/* Close X button - hidden on mobile (use Back instead) */}
<button <button
onClick={onClose} onClick={onClose}
className="p-2 -mr-2 -mt-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition" className="hidden sm:block p-2 -mr-2 -mt-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition"
title="Close" title="Close"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -362,8 +373,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{/* Priority & Source */} {/* Priority & Source */}
<div className="px-6 py-4 border-b border-gray-100"> <div className="px-4 sm:px-6 py-4 border-b border-gray-100">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Priority</h3> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Priority</h3>
{hasToken ? ( {hasToken ? (
@@ -416,7 +427,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div> </div>
{/* Description */} {/* Description */}
<div className="px-6 py-4 border-b border-gray-100"> <div className="px-4 sm:px-6 py-4 border-b border-gray-100">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3>
{hasToken ? ( {hasToken ? (
<EditableText <EditableText
@@ -434,23 +445,23 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div> </div>
{/* Time Info */} {/* Time Info */}
<div className="px-6 py-4 border-b border-gray-100"> <div className="px-4 sm:px-6 py-4 border-b border-gray-100">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Timeline</h3> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Timeline</h3>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm gap-0.5">
<span className="text-gray-500">Created</span> <span className="text-gray-500">Created</span>
<span className="text-gray-700 font-medium">{formatDate(task.createdAt)} <span className="text-gray-400 text-xs">({timeAgo(task.createdAt)})</span></span> <span className="text-gray-700 font-medium text-xs sm:text-sm">{formatDate(task.createdAt)} <span className="text-gray-400 text-xs">({timeAgo(task.createdAt)})</span></span>
</div> </div>
{task.updatedAt !== task.createdAt && ( {task.updatedAt !== task.createdAt && (
<div className="flex items-center justify-between text-sm"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm gap-0.5">
<span className="text-gray-500">Updated</span> <span className="text-gray-500">Updated</span>
<span className="text-gray-700 font-medium">{formatDate(task.updatedAt)} <span className="text-gray-400 text-xs">({timeAgo(task.updatedAt)})</span></span> <span className="text-gray-700 font-medium text-xs sm:text-sm">{formatDate(task.updatedAt)} <span className="text-gray-400 text-xs">({timeAgo(task.updatedAt)})</span></span>
</div> </div>
)} )}
{task.completedAt && ( {task.completedAt && (
<div className="flex items-center justify-between text-sm"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm gap-0.5">
<span className="text-gray-500">Completed</span> <span className="text-gray-500">Completed</span>
<span className="text-gray-700 font-medium">{formatDate(task.completedAt)}</span> <span className="text-gray-700 font-medium text-xs sm:text-sm">{formatDate(task.completedAt)}</span>
</div> </div>
)} )}
{isActive && ( {isActive && (
@@ -463,7 +474,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div> </div>
{/* Progress Notes */} {/* Progress Notes */}
<div className="px-6 py-4"> <div className="px-4 sm:px-6 py-4">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3"> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
Progress Notes {task.progressNotes?.length > 0 && ( Progress Notes {task.progressNotes?.length > 0 && (
<span className="text-gray-300 ml-1">({task.progressNotes.length})</span> <span className="text-gray-300 ml-1">({task.progressNotes.length})</span>
@@ -508,7 +519,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
{/* Save / Cancel Bar */} {/* Save / Cancel Bar */}
{hasToken && isDirty && ( {hasToken && isDirty && (
<div className="px-6 py-3 border-t border-blue-200 bg-blue-50 flex items-center justify-between animate-slide-up"> <div className="px-4 sm:px-6 py-3 border-t border-blue-200 bg-blue-50 flex items-center justify-between animate-slide-up">
<span className="text-sm text-blue-700 font-medium">Unsaved changes</span> <span className="text-sm text-blue-700 font-medium">Unsaved changes</span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -531,7 +542,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
{/* Actions Footer */} {/* Actions Footer */}
{hasToken && actions.length > 0 && ( {hasToken && actions.length > 0 && (
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50"> <div className="px-4 sm:px-6 py-4 border-t border-gray-200 bg-gray-50">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Actions</h3> <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Actions</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{actions.map((action) => ( {actions.map((action) => (

View File

@@ -65,7 +65,7 @@ export function QueuePage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
{/* Page Header */} {/* Page Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 md:top-0 z-30"> <header className="bg-white border-b border-gray-200 sticky top-14 md:top-0 z-30">
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between"> <div className="max-w-4xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center justify-between">
<div> <div>
<h1 className="text-lg sm:text-xl font-bold text-gray-900">Task Queue</h1> <h1 className="text-lg sm:text-xl font-bold text-gray-900">Task Queue</h1>