From c19665a13468373a17c1aeb55fca2385e33fa5d7 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 15:35:02 +0900 Subject: [PATCH] Extract dashboard TaskPanel component (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component --- apps/dashboard/src/App.tsx | 512 +------------------ apps/dashboard/src/TaskPanel.test.ts | 63 +++ apps/dashboard/src/TaskPanel.tsx | 652 +++++++++++++++++++++++++ apps/dashboard/src/dashboardHelpers.ts | 7 +- 4 files changed, 723 insertions(+), 511 deletions(-) create mode 100644 apps/dashboard/src/TaskPanel.test.ts create mode 100644 apps/dashboard/src/TaskPanel.tsx diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 14e9979..bf1b2e2 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -6,8 +6,6 @@ import { type CreateScheduledTaskInput, type DashboardInboxAction, type DashboardRoomActivity, - type DashboardTaskContextMode, - type DashboardTaskScheduleType, type DashboardTaskAction, type DashboardOverview, type DashboardTask, @@ -51,12 +49,12 @@ import { type DashboardFreshness, type DashboardView, } from './DashboardNav'; -import { formatDate, taskActionsFor } from './dashboardHelpers'; +import { formatDate, statusLabel } from './dashboardHelpers'; import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel'; import { RoomBoardV2 } from './RoomBoardV2'; +import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel'; import { EmptyState } from './EmptyState'; import { ParsedBody } from './ParsedBody'; -import { redactSecretsForPreview } from './redaction'; import './styles.css'; interface DashboardState { @@ -69,12 +67,6 @@ type UsageRow = DashboardOverview['usage']['rows'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; type UsageGroup = 'primary' | 'codex'; type UsageLimitWindow = 'h5' | 'd7'; -type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; -type TaskResultTone = 'ok' | 'fail' | 'none'; -type TaskActionKey = - | 'create' - | `${string}:edit` - | `${string}:${DashboardTaskAction}`; type ServiceActionKey = 'stack:restart'; type HealthLevel = 'ok' | 'stale' | 'down'; type FreshnessLevel = DashboardFreshness; @@ -84,12 +76,6 @@ type BeforeInstallPromptEvent = Event & { userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; }; -interface RoomOption { - jid: string; - name: string; - folder: string; -} - const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2'; const DEFAULT_VIEW: DashboardView = 'rooms'; @@ -206,56 +192,6 @@ function canUsePwaCore(): boolean { ); } -function formatTaskDate( - value: string | null | undefined, - locale: Locale, -): string { - if (!value) return '-'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - const time = new Intl.DateTimeFormat(localeTags[locale], { - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); - if (locale === 'ko') - return `${date.getMonth() + 1}월 ${date.getDate()}일 ${time}`; - if (locale === 'ja' || locale === 'zh') - return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; - return new Intl.DateTimeFormat(localeTags[locale], { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).format(date); -} - -function formatRelativeDate( - value: string | null | undefined, - locale: Locale, - t: Messages, -): string { - if (!value) return t.tasks.noTime; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - const diffMs = date.getTime() - Date.now(); - const absMs = Math.abs(diffMs); - if (absMs < 45_000) return t.tasks.now; - - const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ - ['day', 86_400_000], - ['hour', 3_600_000], - ['minute', 60_000], - ]; - const [unit, unitMs] = - units.find(([, threshold]) => absMs >= threshold) ?? units.at(-1)!; - return new Intl.RelativeTimeFormat(localeTags[locale], { - numeric: 'auto', - style: 'short', - }).format(Math.round(diffMs / unitMs), unit); -} - function formatPct(value: number): string { if (value < 0) return '-'; return `${Math.round(value)}%`; @@ -332,11 +268,6 @@ function usageGroup(row: UsageRow): UsageGroup { return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary'; } -function statusLabel(status: string, t: Messages): string { - if (status in t.status) return t.status[status as keyof Messages['status']]; - return status; -} - function formatDuration(value: number | null, t: Messages): string { if (value === null) return '-'; const seconds = Math.floor(value / 1000); @@ -377,46 +308,6 @@ function queueLabel( return parts.join(' · '); } -function safePreview( - value: string | null | undefined, - fallback: string, -): string { - const cleaned = redactSecretsForPreview(value ?? '') - .replace(/<\/?internal[^>]*>/gi, '') - .replace(/\s+/g, ' ') - .trim(); - if (!cleaned) return fallback; - return cleaned.length > 120 ? `${cleaned.slice(0, 120)}...` : cleaned; -} - -function taskGroupKey(task: DashboardTask): TaskGroupKey { - if (task.status === 'completed') return 'completed'; - if (task.status === 'paused') return 'paused'; - if (task.isWatcher) return 'watchers'; - return 'scheduled'; -} - -function taskResultTone(task: DashboardTask): TaskResultTone { - if (!task.lastResult) return 'none'; - const normalized = task.lastResult.toLowerCase(); - if ( - normalized.includes('fail') || - normalized.includes('error') || - normalized.includes('timeout') || - normalized.includes('cancel') || - normalized.includes('reject') - ) { - return 'fail'; - } - return 'ok'; -} - -function taskDisplayName(task: DashboardTask, t: Messages): string { - if (task.isWatcher) return t.tasks.ciWatch; - if (task.scheduleType) return task.scheduleType; - return task.id; -} - function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] { const rooms = new Map(); for (const snapshot of snapshots) { @@ -435,67 +326,6 @@ function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] { ); } -function isTaskScheduleType( - value: FormDataEntryValue | null, -): value is DashboardTaskScheduleType { - return value === 'cron' || value === 'interval' || value === 'once'; -} - -function isTaskContextMode( - value: FormDataEntryValue | null, -): value is DashboardTaskContextMode { - return value === 'group' || value === 'isolated'; -} - -function readRequiredText(form: FormData, name: string): string | null { - const value = form.get(name); - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - -function readTaskForm( - form: FormData, - includeRoom: true, -): CreateScheduledTaskInput | null; -function readTaskForm( - form: FormData, - includeRoom: false, -): UpdateScheduledTaskInput | null; -function readTaskForm( - form: FormData, - includeRoom: boolean, -): CreateScheduledTaskInput | UpdateScheduledTaskInput | null { - const prompt = readRequiredText(form, 'prompt'); - const scheduleValue = readRequiredText(form, 'scheduleValue'); - const scheduleTypeValue = form.get('scheduleType'); - if (!scheduleValue || !isTaskScheduleType(scheduleTypeValue)) { - return null; - } - const scheduleType = scheduleTypeValue; - - if (!includeRoom) { - return prompt - ? { prompt, scheduleType, scheduleValue } - : { scheduleType, scheduleValue }; - } - - if (!prompt) { - return null; - } - - const roomJid = readRequiredText(form, 'roomJid'); - const contextMode = form.get('contextMode'); - if (!roomJid || !isTaskContextMode(contextMode)) return null; - return { - contextMode, - prompt, - roomJid, - scheduleType, - scheduleValue, - }; -} - function serviceAgeMs( service: DashboardOverview['services'][number], generatedAt: string, @@ -1596,344 +1426,6 @@ function UsagePanel({ ); } - -function TaskPanel({ - tasks, - rooms, - locale, - onTaskAction, - onTaskCreate, - onTaskUpdate, - taskActionKey, - t, -}: { - tasks: DashboardTask[]; - rooms: RoomOption[]; - locale: Locale; - onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void; - onTaskCreate: (input: CreateScheduledTaskInput) => void; - onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void; - taskActionKey: TaskActionKey | null; - t: Messages; -}) { - const taskGroups = useMemo(() => { - const groups: Record = { - watchers: [], - scheduled: [], - paused: [], - completed: [], - }; - - for (const task of tasks) { - groups[taskGroupKey(task)].push(task); - } - - for (const groupTasks of Object.values(groups)) { - groupTasks.sort((a, b) => - (a.nextRun ?? a.lastRun ?? a.createdAt).localeCompare( - b.nextRun ?? b.lastRun ?? b.createdAt, - ), - ); - } - - return [ - { key: 'watchers' as const, tasks: groups.watchers }, - { key: 'scheduled' as const, tasks: groups.scheduled }, - { key: 'paused' as const, tasks: groups.paused }, - { key: 'completed' as const, tasks: groups.completed }, - ]; - }, [tasks]); - - return ( -
-
{ - event.preventDefault(); - const form = new FormData(event.currentTarget); - const input = readTaskForm(form, true); - if (!input) return; - onTaskCreate(input); - event.currentTarget.reset(); - }} - > -
- {t.tasks.createTitle} - {t.tasks.createSubtitle} -
- -