Files
maestro/ui/src/components/list/TaskListPanel.tsx
T
oss-sync d061ad08d8
CI / build-and-test (push) Has been cancelled
sync: update from private repo (e62f5c7)
2026-06-11 01:52:48 +00:00

211 lines
7.6 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import { LocalTask } from '../../api';
import { matchText } from '../../lib/utils';
import { COLUMN_LIST, SortMode, StatusColumn } from '../../lib/urlState';
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
import { FilterBar } from './FilterBar';
import { LocalTaskListItem } from './TaskListItem';
import { RailPanel } from './RailPanel';
interface TaskListPanelProps {
localTasks: LocalTask[];
selectedStatus: 'all' | StatusColumn;
sortMode: SortMode;
searchQuery: string;
activeTaskId: number | null;
onStatusChange: (status: 'all' | StatusColumn) => void;
onSortChange: (sort: SortMode) => void;
onSearchChange: (q: string) => void;
onSelectTask: (id: number) => void;
onOpenCreate: () => void;
/**
* Owner scope (mine/all). Only meaningful when scopeEnabled — auth must be
* on and the viewer known; otherwise everything is owner 'local' and the
* control is hidden.
*/
scope?: TaskScope;
onScopeChange?: (scope: TaskScope) => void;
currentUserId?: string | null;
scopeEnabled?: boolean;
/** 'rail' 時は RailPanel を render する。default 'list'。 */
mode?: 'list' | 'rail';
/** rail mode 時の「リストに戻る」ボタンで呼ばれる。 */
onExitFocused?: () => void;
}
function ScopeToggle({
scope,
mineCount,
allCount,
onScopeChange,
}: {
scope: TaskScope;
mineCount: number;
allCount: number;
onScopeChange: (scope: TaskScope) => void;
}) {
const { t } = useTranslation('list');
const seg = (value: TaskScope, label: string, count: number) => (
<button
type="button"
onClick={() => onScopeChange(value)}
aria-pressed={scope === value}
className={`flex-1 px-2 py-1 rounded text-[11px] font-medium transition-colors tabular-nums ${
scope === value
? 'bg-surface text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{label} <span className={scope === value ? 'text-slate-500' : 'text-slate-400'}>{count}</span>
</button>
);
return (
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label={t('scope.aria')}>
{seg('mine', t('scope.mine'), mineCount)}
{seg('all', t('scope.all'), allCount)}
</div>
);
}
export function TaskListPanel({
localTasks: allTasks,
selectedStatus,
sortMode,
searchQuery,
activeTaskId,
onStatusChange,
onSortChange,
onSearchChange,
onSelectTask,
onOpenCreate,
scope = 'mine',
onScopeChange,
currentUserId = null,
scopeEnabled = false,
mode = 'list',
onExitFocused,
}: TaskListPanelProps) {
const { t } = useTranslation('list');
// Owner scope is the outermost filter: status counts / search / sort all
// operate on the scoped list so "自分" mode never counts others' tasks.
const effectiveScope: TaskScope = scopeEnabled ? scope : 'all';
const localTasks = filterTasksByScope(allTasks, effectiveScope, currentUserId);
if (mode === 'rail') {
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
return acc;
}, {} as Record<string, LocalTask[]>);
const allRail = Object.values(localColumnsRail).flat();
const baseRail = selectedStatus === 'all' ? allRail : localColumnsRail[selectedStatus] ?? [];
const filteredRail = baseRail.filter(t =>
matchText(t.title, searchQuery) || matchText(t.body, searchQuery) || matchText(t.pieceName, searchQuery) || matchText(t.ownerName ?? '', searchQuery),
);
return (
<RailPanel
tasks={filteredRail}
activeTaskId={activeTaskId}
onSelectTask={onSelectTask}
onOpenCreate={onOpenCreate}
onExitFocused={onExitFocused ?? (() => {})}
/>
);
}
const localColumns: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
return acc;
}, {} as Record<string, LocalTask[]>);
const allLocalTasks = Object.values(localColumns).flat();
const baseList: LocalTask[] =
selectedStatus === 'all' ? allLocalTasks : localColumns[selectedStatus] ?? [];
const filtered = baseList.filter(t =>
matchText(t.title, searchQuery) || matchText(t.body, searchQuery) || matchText(t.pieceName, searchQuery) || matchText(t.ownerName ?? '', searchQuery)
).sort((a, b) => {
if (sortMode === 'title') {
return a.title.localeCompare(b.title);
}
if (sortMode === 'status') {
const aStatus = a.latestJob?.status ?? 'queued';
const bStatus = b.latestJob?.status ?? 'queued';
return aStatus.localeCompare(bStatus);
}
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
const counts: Record<string, number> = {};
for (const s of COLUMN_LIST) {
counts[s] = localColumns[s]?.length ?? 0;
}
const totalCount = allLocalTasks.length;
const runningCount = counts.running ?? 0;
const waitingCount = (counts.waiting_human ?? 0) + (counts.waiting_subtasks ?? 0);
const failedCount = counts.failed ?? 0;
return (
<div className="flex flex-col h-full overflow-hidden">
<button
type="button"
onClick={onOpenCreate}
className="w-full mb-3 px-3 py-2 bg-accent hover:bg-accent-deep active:scale-[0.98] active:bg-accent-deep text-accent-fg rounded-md text-xs font-semibold inline-flex items-center justify-center gap-1.5 transition-[transform,background-color,color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.25}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
{t('newRequest')}
</button>
{scopeEnabled && onScopeChange && (
<ScopeToggle
scope={scope}
mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length}
allCount={allTasks.length}
onScopeChange={onScopeChange}
/>
)}
<div className="flex items-center gap-3 text-[10px] text-slate-500 px-0.5 pb-2.5 font-mono tabular-nums">
<span><span className="font-semibold text-slate-700">{totalCount}</span> {t('counts.items')}</span>
<span aria-hidden="true" className="text-slate-300">·</span>
<span><span className="font-semibold text-emerald-600">{runningCount}</span> {t('counts.running')}</span>
<span><span className="font-semibold text-amber-600">{waitingCount}</span> {t('counts.waiting')}</span>
{failedCount > 0 && (
<span><span className="font-semibold text-red-600">{failedCount}</span> {t('counts.failed')}</span>
)}
</div>
<FilterBar
selectedStatus={selectedStatus}
sortMode={sortMode}
searchQuery={searchQuery}
counts={counts}
totalCount={totalCount}
onStatusChange={onStatusChange}
onSortChange={onSortChange}
onSearchChange={onSearchChange}
/>
<div className="flex flex-col gap-1.5 mt-2 overflow-y-auto flex-1 min-h-0 pr-0.5">
{filtered.map(task => (
<LocalTaskListItem
key={task.id}
task={task}
active={activeTaskId === task.id}
onClick={() => onSelectTask(task.id)}
/>
))}
{filtered.length === 0 && (
<div className="text-[13px] text-slate-500 px-2 py-3">{t('empty')}</div>
)}
</div>
</div>
);
}