Extract dashboard TaskPanel component (#63)
* 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
This commit is contained in:
@@ -6,8 +6,6 @@ import {
|
|||||||
type CreateScheduledTaskInput,
|
type CreateScheduledTaskInput,
|
||||||
type DashboardInboxAction,
|
type DashboardInboxAction,
|
||||||
type DashboardRoomActivity,
|
type DashboardRoomActivity,
|
||||||
type DashboardTaskContextMode,
|
|
||||||
type DashboardTaskScheduleType,
|
|
||||||
type DashboardTaskAction,
|
type DashboardTaskAction,
|
||||||
type DashboardOverview,
|
type DashboardOverview,
|
||||||
type DashboardTask,
|
type DashboardTask,
|
||||||
@@ -51,12 +49,12 @@ import {
|
|||||||
type DashboardFreshness,
|
type DashboardFreshness,
|
||||||
type DashboardView,
|
type DashboardView,
|
||||||
} from './DashboardNav';
|
} from './DashboardNav';
|
||||||
import { formatDate, taskActionsFor } from './dashboardHelpers';
|
import { formatDate, statusLabel } from './dashboardHelpers';
|
||||||
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
|
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
|
||||||
import { RoomBoardV2 } from './RoomBoardV2';
|
import { RoomBoardV2 } from './RoomBoardV2';
|
||||||
|
import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { ParsedBody } from './ParsedBody';
|
import { ParsedBody } from './ParsedBody';
|
||||||
import { redactSecretsForPreview } from './redaction';
|
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
@@ -69,12 +67,6 @@ type UsageRow = DashboardOverview['usage']['rows'][number];
|
|||||||
type RiskLevel = 'ok' | 'warn' | 'critical';
|
type RiskLevel = 'ok' | 'warn' | 'critical';
|
||||||
type UsageGroup = 'primary' | 'codex';
|
type UsageGroup = 'primary' | 'codex';
|
||||||
type UsageLimitWindow = 'h5' | 'd7';
|
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 ServiceActionKey = 'stack:restart';
|
||||||
type HealthLevel = 'ok' | 'stale' | 'down';
|
type HealthLevel = 'ok' | 'stale' | 'down';
|
||||||
type FreshnessLevel = DashboardFreshness;
|
type FreshnessLevel = DashboardFreshness;
|
||||||
@@ -84,12 +76,6 @@ type BeforeInstallPromptEvent = Event & {
|
|||||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RoomOption {
|
|
||||||
jid: string;
|
|
||||||
name: string;
|
|
||||||
folder: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 15_000;
|
const REFRESH_INTERVAL_MS = 15_000;
|
||||||
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
|
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
|
||||||
const DEFAULT_VIEW: DashboardView = 'rooms';
|
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 {
|
function formatPct(value: number): string {
|
||||||
if (value < 0) return '-';
|
if (value < 0) return '-';
|
||||||
return `${Math.round(value)}%`;
|
return `${Math.round(value)}%`;
|
||||||
@@ -332,11 +268,6 @@ function usageGroup(row: UsageRow): UsageGroup {
|
|||||||
return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary';
|
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 {
|
function formatDuration(value: number | null, t: Messages): string {
|
||||||
if (value === null) return '-';
|
if (value === null) return '-';
|
||||||
const seconds = Math.floor(value / 1000);
|
const seconds = Math.floor(value / 1000);
|
||||||
@@ -377,46 +308,6 @@ function queueLabel(
|
|||||||
return parts.join(' · ');
|
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[] {
|
function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] {
|
||||||
const rooms = new Map<string, RoomOption>();
|
const rooms = new Map<string, RoomOption>();
|
||||||
for (const snapshot of snapshots) {
|
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(
|
function serviceAgeMs(
|
||||||
service: DashboardOverview['services'][number],
|
service: DashboardOverview['services'][number],
|
||||||
generatedAt: string,
|
generatedAt: string,
|
||||||
@@ -1596,344 +1426,6 @@ function UsagePanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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<TaskGroupKey, DashboardTask[]> = {
|
|
||||||
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 (
|
|
||||||
<div className="task-board" aria-label={t.tasks.cardsAria}>
|
|
||||||
<form
|
|
||||||
className="task-create-form"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
const input = readTaskForm(form, true);
|
|
||||||
if (!input) return;
|
|
||||||
onTaskCreate(input);
|
|
||||||
event.currentTarget.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="task-create-head">
|
|
||||||
<span className="eyebrow">{t.tasks.createTitle}</span>
|
|
||||||
<strong>{t.tasks.createSubtitle}</strong>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>{t.tasks.room}</span>
|
|
||||||
<select name="roomJid" required>
|
|
||||||
<option value="">{t.tasks.selectRoom}</option>
|
|
||||||
{rooms.map((room) => (
|
|
||||||
<option key={room.jid} value={room.jid}>
|
|
||||||
{room.name} · {room.folder}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="task-form-wide">
|
|
||||||
<span>{t.tasks.prompt}</span>
|
|
||||||
<textarea
|
|
||||||
name="prompt"
|
|
||||||
placeholder={t.tasks.promptPlaceholder}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{t.tasks.scheduleType}</span>
|
|
||||||
<select name="scheduleType" required>
|
|
||||||
<option value="once">{t.tasks.scheduleTypes.once}</option>
|
|
||||||
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
|
|
||||||
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{t.tasks.scheduleValue}</span>
|
|
||||||
<input
|
|
||||||
name="scheduleValue"
|
|
||||||
placeholder={t.tasks.scheduleValueHint}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{t.tasks.context}</span>
|
|
||||||
<select name="contextMode" required>
|
|
||||||
<option value="isolated">{t.tasks.contextModes.isolated}</option>
|
|
||||||
<option value="group">{t.tasks.contextModes.group}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button disabled={taskActionKey === 'create'} type="submit">
|
|
||||||
{taskActionKey === 'create'
|
|
||||||
? t.tasks.actions.busy
|
|
||||||
: t.tasks.actions.create}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{tasks.length === 0 ? <EmptyState>{t.tasks.empty}</EmptyState> : null}
|
|
||||||
{taskGroups.map((group) => {
|
|
||||||
const label = t.tasks.groups[group.key];
|
|
||||||
const groupHead = (
|
|
||||||
<div className="task-group-head">
|
|
||||||
<div>
|
|
||||||
<span className="eyebrow">{label}</span>
|
|
||||||
<strong>
|
|
||||||
{group.tasks.length} {t.tasks.count}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<span className={`pill pill-${group.key}`}>
|
|
||||||
{group.tasks.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const groupBody =
|
|
||||||
group.tasks.length === 0 ? (
|
|
||||||
<div className="task-group-empty">{t.tasks.groupEmpty}</div>
|
|
||||||
) : (
|
|
||||||
<div className="task-list">
|
|
||||||
{group.tasks.map((task) => {
|
|
||||||
const resultTone = taskResultTone(task);
|
|
||||||
const lastResult = safePreview(
|
|
||||||
task.lastResult,
|
|
||||||
t.tasks.noResult,
|
|
||||||
);
|
|
||||||
const taskActions = taskActionsFor(task);
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className={`task-card task-card-${group.key}`}
|
|
||||||
key={task.id}
|
|
||||||
>
|
|
||||||
<div className="task-card-main">
|
|
||||||
<div className="task-title">
|
|
||||||
<strong>{taskDisplayName(task, t)}</strong>
|
|
||||||
<span className="mono-chip">{task.groupFolder}</span>
|
|
||||||
</div>
|
|
||||||
<div className="task-status-line">
|
|
||||||
<span className={`pill pill-${task.status}`}>
|
|
||||||
{statusLabel(task.status, t)}
|
|
||||||
</span>
|
|
||||||
{task.ciProvider ? (
|
|
||||||
<span className="task-provider">
|
|
||||||
{task.ciProvider}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{taskActions.length > 0 ? (
|
|
||||||
<div className="task-actions">
|
|
||||||
{taskActions.map((action) => {
|
|
||||||
const actionKey: TaskActionKey = `${task.id}:${action}`;
|
|
||||||
const busy = taskActionKey === actionKey;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-busy={busy || undefined}
|
|
||||||
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
|
|
||||||
disabled={busy}
|
|
||||||
key={action}
|
|
||||||
onClick={() => onTaskAction(task, action)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{busy
|
|
||||||
? t.tasks.actions.busy
|
|
||||||
: t.tasks.actions[action]}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="task-time-grid">
|
|
||||||
<span>
|
|
||||||
<small>{t.tasks.next}</small>
|
|
||||||
<strong>{formatTaskDate(task.nextRun, locale)}</strong>
|
|
||||||
<em>{formatRelativeDate(task.nextRun, locale, t)}</em>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<small>{t.tasks.last}</small>
|
|
||||||
<strong>{formatTaskDate(task.lastRun, locale)}</strong>
|
|
||||||
<em>{formatRelativeDate(task.lastRun, locale, t)}</em>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<small>{t.tasks.schedule}</small>
|
|
||||||
<strong>{task.scheduleType}</strong>
|
|
||||||
<em>{task.scheduleValue}</em>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{task.suspendedUntil ? (
|
|
||||||
<div className="task-suspended">
|
|
||||||
<span>{t.tasks.suspendedUntil}</span>
|
|
||||||
<strong>
|
|
||||||
{formatTaskDate(task.suspendedUntil, locale)}
|
|
||||||
</strong>
|
|
||||||
<em>
|
|
||||||
{formatRelativeDate(task.suspendedUntil, locale, t)}
|
|
||||||
</em>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className={`task-result result-${resultTone}`}>
|
|
||||||
<span>
|
|
||||||
{resultTone === 'fail'
|
|
||||||
? t.tasks.resultFail
|
|
||||||
: resultTone === 'ok'
|
|
||||||
? t.tasks.resultOk
|
|
||||||
: t.tasks.result}
|
|
||||||
</span>
|
|
||||||
<strong>{lastResult}</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details className="task-prompt">
|
|
||||||
<summary>{t.tasks.prompt}</summary>
|
|
||||||
<p>
|
|
||||||
{safePreview(task.promptPreview, t.tasks.emptyPrompt)}
|
|
||||||
</p>
|
|
||||||
<small>
|
|
||||||
{task.id} · {task.contextMode} · {task.promptLength}{' '}
|
|
||||||
{t.units.chars}
|
|
||||||
</small>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
{!task.isWatcher && task.status !== 'completed' ? (
|
|
||||||
<details className="task-edit">
|
|
||||||
<summary>{t.tasks.actions.edit}</summary>
|
|
||||||
<form
|
|
||||||
className="task-edit-form"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = new FormData(event.currentTarget);
|
|
||||||
const input = readTaskForm(form, false);
|
|
||||||
if (!input) return;
|
|
||||||
onTaskUpdate(task, input);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="task-form-wide">
|
|
||||||
<span>{t.tasks.prompt}</span>
|
|
||||||
<textarea
|
|
||||||
name="prompt"
|
|
||||||
placeholder={t.tasks.editPromptPlaceholder}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{t.tasks.scheduleType}</span>
|
|
||||||
<select
|
|
||||||
name="scheduleType"
|
|
||||||
defaultValue={task.scheduleType}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="once">
|
|
||||||
{t.tasks.scheduleTypes.once}
|
|
||||||
</option>
|
|
||||||
<option value="interval">
|
|
||||||
{t.tasks.scheduleTypes.interval}
|
|
||||||
</option>
|
|
||||||
<option value="cron">
|
|
||||||
{t.tasks.scheduleTypes.cron}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>{t.tasks.scheduleValue}</span>
|
|
||||||
<input
|
|
||||||
name="scheduleValue"
|
|
||||||
defaultValue={task.scheduleValue}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
disabled={taskActionKey === `${task.id}:edit`}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{taskActionKey === `${task.id}:edit`
|
|
||||||
? t.tasks.actions.busy
|
|
||||||
: t.tasks.actions.save}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (group.key === 'completed') {
|
|
||||||
return (
|
|
||||||
<details
|
|
||||||
className="task-group task-group-completed"
|
|
||||||
key={group.key}
|
|
||||||
>
|
|
||||||
<summary className="task-group-head">
|
|
||||||
<div>
|
|
||||||
<span className="eyebrow">{label}</span>
|
|
||||||
<strong>
|
|
||||||
{group.tasks.length} {t.tasks.count}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<span className={`pill pill-${group.key}`}>
|
|
||||||
{group.tasks.length}
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
{groupBody}
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`task-group task-group-${group.key}`}
|
|
||||||
key={group.key}
|
|
||||||
>
|
|
||||||
{groupHead}
|
|
||||||
{groupBody}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardErrorCard({
|
function DashboardErrorCard({
|
||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
|||||||
63
apps/dashboard/src/TaskPanel.test.ts
Normal file
63
apps/dashboard/src/TaskPanel.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { DashboardTask } from './api';
|
||||||
|
import { messages } from './i18n';
|
||||||
|
import { TaskPanel, type TaskPanelProps } from './TaskPanel';
|
||||||
|
|
||||||
|
const t = messages.en;
|
||||||
|
|
||||||
|
const task: DashboardTask = {
|
||||||
|
agentType: 'codex',
|
||||||
|
chatJid: 'room-1',
|
||||||
|
ciMetadata: null,
|
||||||
|
ciProvider: null,
|
||||||
|
contextMode: 'group',
|
||||||
|
createdAt: '2026-04-28T04:00:00.000Z',
|
||||||
|
groupFolder: 'eyejokerdb',
|
||||||
|
id: 'task-1',
|
||||||
|
isWatcher: false,
|
||||||
|
lastResult: '<internal>hidden</internal>Task completed',
|
||||||
|
lastRun: '2026-04-28T04:30:00.000Z',
|
||||||
|
nextRun: '2026-04-28T05:00:00.000Z',
|
||||||
|
promptLength: 42,
|
||||||
|
promptPreview: '<internal>hidden</internal>Run production check',
|
||||||
|
scheduleType: 'interval',
|
||||||
|
scheduleValue: '10m',
|
||||||
|
status: 'active',
|
||||||
|
suspendedUntil: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseProps: TaskPanelProps = {
|
||||||
|
locale: 'en',
|
||||||
|
onTaskAction: () => {},
|
||||||
|
onTaskCreate: () => {},
|
||||||
|
onTaskUpdate: () => {},
|
||||||
|
rooms: [{ folder: 'eyejokerdb', jid: 'room-1', name: 'eyejokerdb-main' }],
|
||||||
|
taskActionKey: null,
|
||||||
|
tasks: [task],
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TaskPanel', () => {
|
||||||
|
it('renders task groups, actions, and sanitized previews', () => {
|
||||||
|
const html = renderToStaticMarkup(createElement(TaskPanel, baseProps));
|
||||||
|
|
||||||
|
expect(html).toContain(t.tasks.groups.scheduled);
|
||||||
|
expect(html).toContain('eyejokerdb-main');
|
||||||
|
expect(html).toContain(t.tasks.actions.pause);
|
||||||
|
expect(html).toContain(t.tasks.actions.cancel);
|
||||||
|
expect(html).toContain('Task completed');
|
||||||
|
expect(html).toContain('Run production check');
|
||||||
|
expect(html).not.toContain('internal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an empty state without scheduled tasks', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(TaskPanel, { ...baseProps, tasks: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain(t.tasks.empty);
|
||||||
|
});
|
||||||
|
});
|
||||||
652
apps/dashboard/src/TaskPanel.tsx
Normal file
652
apps/dashboard/src/TaskPanel.tsx
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CreateScheduledTaskInput,
|
||||||
|
DashboardTask,
|
||||||
|
DashboardTaskAction,
|
||||||
|
DashboardTaskContextMode,
|
||||||
|
DashboardTaskScheduleType,
|
||||||
|
UpdateScheduledTaskInput,
|
||||||
|
} from './api';
|
||||||
|
import { EmptyState } from './EmptyState';
|
||||||
|
import { localeTags, type Locale, type Messages } from './i18n';
|
||||||
|
import { redactSecretsForPreview } from './redaction';
|
||||||
|
import { statusLabel, taskActionsFor } from './dashboardHelpers';
|
||||||
|
|
||||||
|
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
|
||||||
|
type TaskResultTone = 'ok' | 'fail' | 'none';
|
||||||
|
|
||||||
|
export type TaskActionKey =
|
||||||
|
| 'create'
|
||||||
|
| `${string}:edit`
|
||||||
|
| `${string}:${DashboardTaskAction}`;
|
||||||
|
|
||||||
|
export interface RoomOption {
|
||||||
|
jid: string;
|
||||||
|
name: string;
|
||||||
|
folder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskPanelProps {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskGroup {
|
||||||
|
key: TaskGroupKey;
|
||||||
|
tasks: DashboardTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskCreateFormProps {
|
||||||
|
rooms: RoomOption[];
|
||||||
|
onTaskCreate: (input: CreateScheduledTaskInput) => void;
|
||||||
|
taskActionKey: TaskActionKey | null;
|
||||||
|
t: Messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskGroupSectionProps {
|
||||||
|
group: TaskGroup;
|
||||||
|
locale: Locale;
|
||||||
|
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
||||||
|
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
|
||||||
|
taskActionKey: TaskActionKey | null;
|
||||||
|
t: Messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
groupKey: TaskGroupKey;
|
||||||
|
locale: Locale;
|
||||||
|
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
||||||
|
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
|
||||||
|
task: DashboardTask;
|
||||||
|
taskActionKey: TaskActionKey | null;
|
||||||
|
t: Messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskActionButtonsProps {
|
||||||
|
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
||||||
|
task: DashboardTask;
|
||||||
|
taskActionKey: TaskActionKey | null;
|
||||||
|
t: Messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskDateProps {
|
||||||
|
locale: Locale;
|
||||||
|
t: Messages;
|
||||||
|
task: DashboardTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskEditFormProps {
|
||||||
|
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
|
||||||
|
task: DashboardTask;
|
||||||
|
taskActionKey: TaskActionKey | null;
|
||||||
|
t: Messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 buildTaskGroups(tasks: DashboardTask[]): TaskGroup[] {
|
||||||
|
const groups: Record<TaskGroupKey, DashboardTask[]> = {
|
||||||
|
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', tasks: groups.watchers },
|
||||||
|
{ key: 'scheduled', tasks: groups.scheduled },
|
||||||
|
{ key: 'paused', tasks: groups.paused },
|
||||||
|
{ key: 'completed', tasks: groups.completed },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCreateForm({
|
||||||
|
rooms,
|
||||||
|
onTaskCreate,
|
||||||
|
taskActionKey,
|
||||||
|
t,
|
||||||
|
}: TaskCreateFormProps) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="task-create-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const input = readTaskForm(form, true);
|
||||||
|
if (!input) return;
|
||||||
|
onTaskCreate(input);
|
||||||
|
event.currentTarget.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="task-create-head">
|
||||||
|
<span className="eyebrow">{t.tasks.createTitle}</span>
|
||||||
|
<strong>{t.tasks.createSubtitle}</strong>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>{t.tasks.room}</span>
|
||||||
|
<select name="roomJid" required>
|
||||||
|
<option value="">{t.tasks.selectRoom}</option>
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<option key={room.jid} value={room.jid}>
|
||||||
|
{room.name} · {room.folder}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="task-form-wide">
|
||||||
|
<span>{t.tasks.prompt}</span>
|
||||||
|
<textarea
|
||||||
|
name="prompt"
|
||||||
|
placeholder={t.tasks.promptPlaceholder}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t.tasks.scheduleType}</span>
|
||||||
|
<select name="scheduleType" required>
|
||||||
|
<option value="once">{t.tasks.scheduleTypes.once}</option>
|
||||||
|
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
|
||||||
|
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t.tasks.scheduleValue}</span>
|
||||||
|
<input
|
||||||
|
name="scheduleValue"
|
||||||
|
placeholder={t.tasks.scheduleValueHint}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t.tasks.context}</span>
|
||||||
|
<select name="contextMode" required>
|
||||||
|
<option value="isolated">{t.tasks.contextModes.isolated}</option>
|
||||||
|
<option value="group">{t.tasks.contextModes.group}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button disabled={taskActionKey === 'create'} type="submit">
|
||||||
|
{taskActionKey === 'create'
|
||||||
|
? t.tasks.actions.busy
|
||||||
|
: t.tasks.actions.create}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskActionButtons({
|
||||||
|
onTaskAction,
|
||||||
|
task,
|
||||||
|
taskActionKey,
|
||||||
|
t,
|
||||||
|
}: TaskActionButtonsProps) {
|
||||||
|
const taskActions = taskActionsFor(task);
|
||||||
|
if (taskActions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-actions">
|
||||||
|
{taskActions.map((action) => {
|
||||||
|
const actionKey: TaskActionKey = `${task.id}:${action}`;
|
||||||
|
const busy = taskActionKey === actionKey;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
|
||||||
|
disabled={busy}
|
||||||
|
key={action}
|
||||||
|
onClick={() => onTaskAction(task, action)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{busy ? t.tasks.actions.busy : t.tasks.actions[action]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskTimeGrid({ locale, t, task }: TaskDateProps) {
|
||||||
|
return (
|
||||||
|
<div className="task-time-grid">
|
||||||
|
<span>
|
||||||
|
<small>{t.tasks.next}</small>
|
||||||
|
<strong>{formatTaskDate(task.nextRun, locale)}</strong>
|
||||||
|
<em>{formatRelativeDate(task.nextRun, locale, t)}</em>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<small>{t.tasks.last}</small>
|
||||||
|
<strong>{formatTaskDate(task.lastRun, locale)}</strong>
|
||||||
|
<em>{formatRelativeDate(task.lastRun, locale, t)}</em>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<small>{t.tasks.schedule}</small>
|
||||||
|
<strong>{task.scheduleType}</strong>
|
||||||
|
<em>{task.scheduleValue}</em>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskSuspendedUntil({ locale, t, task }: TaskDateProps) {
|
||||||
|
if (!task.suspendedUntil) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-suspended">
|
||||||
|
<span>{t.tasks.suspendedUntil}</span>
|
||||||
|
<strong>{formatTaskDate(task.suspendedUntil, locale)}</strong>
|
||||||
|
<em>{formatRelativeDate(task.suspendedUntil, locale, t)}</em>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskResult({ task, t }: { task: DashboardTask; t: Messages }) {
|
||||||
|
const resultTone = taskResultTone(task);
|
||||||
|
const lastResult = safePreview(task.lastResult, t.tasks.noResult);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`task-result result-${resultTone}`}>
|
||||||
|
<span>
|
||||||
|
{resultTone === 'fail'
|
||||||
|
? t.tasks.resultFail
|
||||||
|
: resultTone === 'ok'
|
||||||
|
? t.tasks.resultOk
|
||||||
|
: t.tasks.result}
|
||||||
|
</span>
|
||||||
|
<strong>{lastResult}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskPromptDetails({ task, t }: { task: DashboardTask; t: Messages }) {
|
||||||
|
return (
|
||||||
|
<details className="task-prompt">
|
||||||
|
<summary>{t.tasks.prompt}</summary>
|
||||||
|
<p>{safePreview(task.promptPreview, t.tasks.emptyPrompt)}</p>
|
||||||
|
<small>
|
||||||
|
{task.id} · {task.contextMode} · {task.promptLength} {t.units.chars}
|
||||||
|
</small>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskEditForm({
|
||||||
|
onTaskUpdate,
|
||||||
|
task,
|
||||||
|
taskActionKey,
|
||||||
|
t,
|
||||||
|
}: TaskEditFormProps) {
|
||||||
|
if (task.isWatcher || task.status === 'completed') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="task-edit">
|
||||||
|
<summary>{t.tasks.actions.edit}</summary>
|
||||||
|
<form
|
||||||
|
className="task-edit-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const input = readTaskForm(form, false);
|
||||||
|
if (!input) return;
|
||||||
|
onTaskUpdate(task, input);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="task-form-wide">
|
||||||
|
<span>{t.tasks.prompt}</span>
|
||||||
|
<textarea name="prompt" placeholder={t.tasks.editPromptPlaceholder} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t.tasks.scheduleType}</span>
|
||||||
|
<select name="scheduleType" defaultValue={task.scheduleType} required>
|
||||||
|
<option value="once">{t.tasks.scheduleTypes.once}</option>
|
||||||
|
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
|
||||||
|
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t.tasks.scheduleValue}</span>
|
||||||
|
<input
|
||||||
|
name="scheduleValue"
|
||||||
|
defaultValue={task.scheduleValue}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button disabled={taskActionKey === `${task.id}:edit`} type="submit">
|
||||||
|
{taskActionKey === `${task.id}:edit`
|
||||||
|
? t.tasks.actions.busy
|
||||||
|
: t.tasks.actions.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({
|
||||||
|
groupKey,
|
||||||
|
locale,
|
||||||
|
onTaskAction,
|
||||||
|
onTaskUpdate,
|
||||||
|
task,
|
||||||
|
taskActionKey,
|
||||||
|
t,
|
||||||
|
}: TaskCardProps) {
|
||||||
|
return (
|
||||||
|
<article className={`task-card task-card-${groupKey}`}>
|
||||||
|
<div className="task-card-main">
|
||||||
|
<div className="task-title">
|
||||||
|
<strong>{taskDisplayName(task, t)}</strong>
|
||||||
|
<span className="mono-chip">{task.groupFolder}</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-status-line">
|
||||||
|
<span className={`pill pill-${task.status}`}>
|
||||||
|
{statusLabel(task.status, t)}
|
||||||
|
</span>
|
||||||
|
{task.ciProvider ? (
|
||||||
|
<span className="task-provider">{task.ciProvider}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TaskActionButtons
|
||||||
|
onTaskAction={onTaskAction}
|
||||||
|
task={task}
|
||||||
|
taskActionKey={taskActionKey}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<TaskTimeGrid locale={locale} t={t} task={task} />
|
||||||
|
<TaskSuspendedUntil locale={locale} t={t} task={task} />
|
||||||
|
<TaskResult task={task} t={t} />
|
||||||
|
<TaskPromptDetails task={task} t={t} />
|
||||||
|
<TaskEditForm
|
||||||
|
onTaskUpdate={onTaskUpdate}
|
||||||
|
task={task}
|
||||||
|
taskActionKey={taskActionKey}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskGroupBody({
|
||||||
|
group,
|
||||||
|
locale,
|
||||||
|
onTaskAction,
|
||||||
|
onTaskUpdate,
|
||||||
|
taskActionKey,
|
||||||
|
t,
|
||||||
|
}: TaskGroupSectionProps) {
|
||||||
|
if (group.tasks.length === 0) {
|
||||||
|
return <div className="task-group-empty">{t.tasks.groupEmpty}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-list">
|
||||||
|
{group.tasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
groupKey={group.key}
|
||||||
|
key={task.id}
|
||||||
|
locale={locale}
|
||||||
|
onTaskAction={onTaskAction}
|
||||||
|
onTaskUpdate={onTaskUpdate}
|
||||||
|
task={task}
|
||||||
|
taskActionKey={taskActionKey}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskGroupHead({
|
||||||
|
countLabel,
|
||||||
|
group,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
countLabel: string;
|
||||||
|
group: TaskGroup;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="task-group-head">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">{label}</span>
|
||||||
|
<strong>
|
||||||
|
{group.tasks.length} {countLabel}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<span className={`pill pill-${group.key}`}>{group.tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskGroupSection(props: TaskGroupSectionProps) {
|
||||||
|
const { group, t } = props;
|
||||||
|
const label = t.tasks.groups[group.key];
|
||||||
|
const body = <TaskGroupBody {...props} />;
|
||||||
|
|
||||||
|
if (group.key === 'completed') {
|
||||||
|
return (
|
||||||
|
<details className="task-group task-group-completed">
|
||||||
|
<summary className="task-group-head">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">{label}</span>
|
||||||
|
<strong>
|
||||||
|
{group.tasks.length} {t.tasks.count}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<span className={`pill pill-${group.key}`}>{group.tasks.length}</span>
|
||||||
|
</summary>
|
||||||
|
{body}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`task-group task-group-${group.key}`}>
|
||||||
|
<TaskGroupHead countLabel={t.tasks.count} group={group} label={label} />
|
||||||
|
{body}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskPanel({
|
||||||
|
tasks,
|
||||||
|
rooms,
|
||||||
|
locale,
|
||||||
|
onTaskAction,
|
||||||
|
onTaskCreate,
|
||||||
|
onTaskUpdate,
|
||||||
|
taskActionKey,
|
||||||
|
t,
|
||||||
|
}: TaskPanelProps) {
|
||||||
|
const taskGroups = useMemo(() => buildTaskGroups(tasks), [tasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-board" aria-label={t.tasks.cardsAria}>
|
||||||
|
<TaskCreateForm
|
||||||
|
rooms={rooms}
|
||||||
|
onTaskCreate={onTaskCreate}
|
||||||
|
taskActionKey={taskActionKey}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tasks.length === 0 ? <EmptyState>{t.tasks.empty}</EmptyState> : null}
|
||||||
|
{taskGroups.map((group) => (
|
||||||
|
<TaskGroupSection
|
||||||
|
group={group}
|
||||||
|
key={group.key}
|
||||||
|
locale={locale}
|
||||||
|
onTaskAction={onTaskAction}
|
||||||
|
onTaskUpdate={onTaskUpdate}
|
||||||
|
taskActionKey={taskActionKey}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { DashboardTask, DashboardTaskAction } from './api';
|
import type { DashboardTask, DashboardTaskAction } from './api';
|
||||||
import { localeTags, type Locale } from './i18n';
|
import { localeTags, type Locale, type Messages } from './i18n';
|
||||||
|
|
||||||
export function formatDate(
|
export function formatDate(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
@@ -62,3 +62,8 @@ export function taskActionsFor(task: DashboardTask): DashboardTaskAction[] {
|
|||||||
if (task.status === 'paused') return ['resume', 'cancel'];
|
if (task.status === 'paused') return ['resume', 'cancel'];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function statusLabel(status: string, t: Messages): string {
|
||||||
|
if (status in t.status) return t.status[status as keyof Messages['status']];
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user