Refine scheduled task dashboard UX (#135)

* add gated codex goals support

* sync README SDK versions

* bump claude agent sdk

* add codex goals settings toggle

* reuse status dashboard message and simplify settings actions

* refine settings page UX

* fix settings nav hash routing

* add dashboard UX verification

* refine dashboard settings and inbox UX

* remove inbox top-level navigation

* remove dashboard health top-level navigation

* fix: allow runtime image attachment paths

* feat: configure attachment allowlist dirs

* fix: clean duplicate dashboard status messages

* fix: poll dashboard duplicate cleanup between status updates

* fix: delete dashboard duplicates on create

* Refine settings IA with tabbed sections

* Add read-only runtime inventory settings

* Fix runtime inventory MCP JSON parsing

* Add room skill settings inventory

* Add room skill setting mutations

* Apply room skill overrides to runner spawn

* Refine scheduled task dashboard UX
This commit is contained in:
Eyejoker
2026-05-04 16:38:21 +09:00
committed by GitHub
parent 0a8decde8c
commit 8c24082b19
6 changed files with 1000 additions and 547 deletions

View File

@@ -0,0 +1,688 @@
.task-board {
display: grid;
gap: 14px;
}
.task-command-center {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(210px, 0.75fr) minmax(
260px,
1fr
);
gap: 12px;
align-items: stretch;
padding: 16px;
border: 1px solid var(--panel-border);
border-radius: 26px;
background:
radial-gradient(circle at 12% 0%, rgba(191, 95, 44, 0.18), transparent 34%),
linear-gradient(
135deg,
rgba(255, 255, 255, 0.065),
rgba(255, 255, 255, 0.025)
);
box-shadow: 0 18px 48px rgba(16, 10, 6, 0.08);
}
.task-command-copy,
.task-command-next,
.task-summary-metric {
min-width: 0;
}
.task-command-copy strong {
display: block;
margin-top: 4px;
font-size: clamp(23px, 4vw, 34px);
line-height: 1;
letter-spacing: -0.06em;
}
.task-command-copy p {
max-width: 520px;
margin: 9px 0 0;
overflow: hidden;
color: var(--muted);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-command-next {
display: grid;
gap: 3px;
align-content: center;
padding: 12px;
border: 1px solid rgba(191, 95, 44, 0.22);
border-radius: 18px;
background: rgba(191, 95, 44, 0.08);
}
.task-command-next small,
.task-command-next span,
.task-summary-metric small {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.task-command-next strong {
overflow: hidden;
font-size: 21px;
letter-spacing: -0.04em;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-command-next span {
overflow: hidden;
text-transform: none;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-command-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.task-summary-metric {
display: grid;
gap: 4px;
align-content: center;
min-height: 70px;
padding: 10px 12px;
border: 1px solid var(--panel-border);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
}
.task-summary-metric strong {
font-size: 24px;
line-height: 1;
letter-spacing: -0.05em;
}
.task-create-form {
display: grid;
grid-template-columns: minmax(180px, 0.42fr) minmax(0, 1fr);
gap: 12px;
align-items: stretch;
padding: 12px;
border: 1px solid var(--panel-border);
border-radius: 24px;
background: rgba(255, 255, 255, 0.04);
}
.task-edit-form {
display: grid;
grid-template-columns:
minmax(0, 1.4fr) minmax(110px, 0.6fr) minmax(0, 1fr)
auto;
gap: 8px;
align-items: end;
margin-top: 8px;
padding: 9px;
border: 1px solid var(--panel-border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
}
.task-create-head {
display: grid;
align-content: center;
padding: 14px;
border: 1px solid rgba(191, 95, 44, 0.2);
border-radius: 20px;
background:
linear-gradient(145deg, rgba(191, 95, 44, 0.14), transparent),
rgba(255, 255, 255, 0.035);
}
.task-create-head strong {
display: block;
margin-top: 5px;
font-size: 22px;
line-height: 1.06;
letter-spacing: -0.05em;
}
.task-create-head p {
margin: 10px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.task-create-body {
display: grid;
gap: 10px;
}
.task-form-controls {
display: grid;
grid-template-columns: minmax(160px, 1.2fr) repeat(3, minmax(110px, 1fr));
gap: 8px;
}
.task-form-footer {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.task-form-footer > span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: -0.03em;
}
.task-create-form label,
.task-edit-form label {
display: grid;
min-width: 0;
gap: 4px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.task-create-form input,
.task-create-form select,
.task-create-form textarea,
.task-edit-form input,
.task-edit-form select,
.task-edit-form textarea {
width: 100%;
min-height: 44px;
border: 1px solid var(--panel-border-strong);
border-radius: 15px;
padding: 10px 12px;
color: var(--ink);
background: rgba(255, 255, 255, 0.04);
font: inherit;
font-size: 13px;
}
.task-create-form textarea,
.task-edit-form textarea {
min-height: 82px;
resize: vertical;
}
.task-edit-form textarea {
min-height: 44px;
}
.task-create-form input:focus-visible,
.task-create-form select:focus-visible,
.task-create-form textarea:focus-visible,
.task-edit-form input:focus-visible,
.task-edit-form select:focus-visible,
.task-edit-form textarea:focus-visible {
outline: 3px solid rgba(191, 95, 44, 0.18);
}
.task-create-form button,
.task-edit-form button {
min-height: 44px;
border: 0;
border-radius: 999px;
padding: 0 18px;
color: var(--bg);
background: var(--accent);
font: inherit;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.task-create-form button:disabled,
.task-edit-form button:disabled {
cursor: progress;
opacity: 0.55;
}
.task-form-wide {
grid-column: span 2;
}
.task-create-form .task-form-wide {
grid-column: auto;
}
.task-lanes {
display: grid;
gap: 12px;
}
.task-group {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid var(--panel-border);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent),
rgba(255, 255, 255, 0.018);
}
.task-group-completed {
display: block;
}
.task-group-completed[open] {
display: grid;
}
.task-group-completed summary {
cursor: pointer;
list-style: none;
}
.task-group-completed summary::-webkit-details-marker {
display: none;
}
.task-group-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.task-group-head strong {
display: block;
margin-top: 2px;
font-size: 18px;
letter-spacing: -0.03em;
}
.task-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
}
.task-card {
display: grid;
gap: 10px;
min-width: 0;
padding: 15px;
border: 1px solid var(--panel-border);
border-radius: 20px;
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.055), transparent 58%),
var(--bg-card);
box-shadow: 0 14px 34px rgba(15, 10, 7, 0.05);
}
.task-card-main,
.task-time-grid,
.task-suspended,
.task-result {
min-width: 0;
}
.task-card-main {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
}
.task-title {
display: grid;
gap: 5px;
min-width: 0;
}
.task-kind {
color: var(--accent-strong);
font-size: 11px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.task-title strong {
display: block;
overflow: hidden;
font-size: 16px;
letter-spacing: -0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-meta-strip {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.task-meta-strip span {
display: inline-flex;
max-width: 100%;
min-height: 26px;
align-items: center;
padding: 0 9px;
overflow: hidden;
border-radius: 999px;
color: var(--muted);
font-size: 11px;
font-weight: 800;
background: rgba(255, 255, 255, 0.05);
text-overflow: ellipsis;
white-space: nowrap;
}
.task-status-line {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.task-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.task-action {
min-height: 44px;
padding: 0 14px;
border: 1px solid var(--panel-border-strong);
border-radius: 999px;
color: var(--ink);
font: inherit;
font-size: 12px;
font-weight: 800;
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
.task-action:hover,
.task-action:focus-visible {
border-color: rgba(191, 95, 44, 0.42);
outline: none;
box-shadow: 0 0 0 3px rgba(191, 95, 44, 0.12);
}
.task-action:disabled {
cursor: progress;
opacity: 0.55;
}
.task-action.is-busy {
display: inline-flex;
gap: 7px;
align-items: center;
justify-content: center;
cursor: progress;
opacity: 0.85;
background: rgba(191, 95, 44, 0.12);
border-color: rgba(191, 95, 44, 0.32);
}
.task-action.is-busy::before {
content: '';
display: block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ej-spin 0.7s linear infinite;
}
@keyframes ej-spin {
to {
transform: rotate(360deg);
}
}
.task-action-cancel {
color: #8f2f22;
border-color: rgba(183, 71, 52, 0.22);
background: rgba(183, 71, 52, 0.08);
}
.task-time-grid {
display: grid;
grid-template-columns: minmax(150px, 1.2fr) repeat(2, minmax(0, 0.9fr));
gap: 8px;
}
.task-time-grid > span,
.task-suspended,
.task-result {
padding: 9px;
border: 1px solid var(--panel-border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.025);
}
.task-next-run {
border-color: rgba(191, 95, 44, 0.25) !important;
background: rgba(191, 95, 44, 0.08) !important;
}
.task-time-grid small,
.task-suspended span,
.task-result span,
.task-time-grid em,
.task-suspended em {
color: var(--muted);
font-size: 11px;
font-style: normal;
font-weight: 600;
}
.task-time-grid small,
.task-time-grid strong,
.task-time-grid em,
.task-suspended span,
.task-suspended strong,
.task-suspended em,
.task-result span,
.task-result strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-time-grid strong,
.task-suspended strong,
.task-result strong {
margin-top: 3px;
}
.task-next-run strong {
font-size: 18px;
letter-spacing: -0.03em;
}
.task-suspended {
border-color: rgba(181, 138, 34, 0.22);
background: rgba(181, 138, 34, 0.08);
}
.task-result {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: center;
border-color: rgba(63, 127, 81, 0.15);
}
.result-fail {
border-color: rgba(183, 71, 52, 0.28);
background: rgba(183, 71, 52, 0.08);
}
.result-none {
border-color: rgba(109, 115, 95, 0.14);
}
.task-prompt {
color: var(--muted);
}
.task-edit summary,
.task-prompt summary {
min-height: 32px;
color: var(--accent-strong);
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.task-prompt p {
margin: 4px 0;
overflow-wrap: anywhere;
}
.task-prompt small {
display: block;
overflow: hidden;
font-family:
'SF Mono', 'Cascadia Code', 'Roboto Mono', ui-monospace, monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-group-empty {
padding: 12px;
border-radius: 14px;
color: var(--muted);
background: rgba(255, 255, 255, 0.025);
}
.pill-watchers {
color: #173e23;
background: rgba(63, 127, 81, 0.18);
}
.pill-scheduled {
color: #6e4a05;
background: rgba(181, 138, 34, 0.16);
}
.pill-paused {
color: #6e4a05;
background: rgba(181, 138, 34, 0.2);
}
.pill-completed {
color: #625d52;
background: rgba(109, 115, 95, 0.16);
}
.task-card > p {
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.task-command-center,
.task-create-form,
.task-form-controls,
.task-card-main {
grid-template-columns: 1fr;
}
.task-command-center {
padding: 12px;
}
.task-command-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.task-summary-metric {
min-height: 62px;
}
.task-create-head {
padding: 12px;
}
.task-create-head strong {
font-size: 19px;
}
.task-form-footer {
display: grid;
}
.task-form-footer button {
width: 100%;
}
.task-group {
padding: 10px;
}
.task-edit-form {
grid-template-columns: 1fr;
gap: 7px;
padding: 10px;
}
.task-form-wide {
grid-column: auto;
}
.task-list {
grid-template-columns: 1fr;
gap: 8px;
}
.task-card {
gap: 7px;
padding: 9px;
}
.task-action {
min-height: 44px;
padding-inline: 12px;
}
.task-card-main {
gap: 7px;
}
.task-time-grid {
grid-template-columns: 1fr;
gap: 6px;
}
.task-time-grid > span,
.task-suspended,
.task-result {
padding: 7px;
}
.task-prompt summary {
min-height: 44px;
}
}
@media (max-width: 380px) {
.task-command-metrics {
grid-template-columns: 1fr;
}
.task-time-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -46,6 +46,9 @@ describe('TaskPanel', () => {
expect(html).toContain(t.tasks.groups.scheduled);
expect(html).toContain('eyejokerdb-main');
expect(html).toContain(t.panels.scheduled);
expect(html).toContain(t.tasks.next);
expect(html).toContain('10m');
expect(html).toContain(t.tasks.actions.pause);
expect(html).toContain(t.tasks.actions.cancel);
expect(html).toContain('Task completed');

View File

@@ -13,6 +13,7 @@ import { localeTags, type Locale, type Messages } from './i18n';
import { redactSecretsForPreview } from './redaction';
import { statusLabel } from './dashboardHelpers';
import { TaskActionButtons, type TaskActionKey } from './TaskActionButtons';
import './TaskPanel.css';
export type { TaskActionKey } from './TaskActionButtons';
@@ -41,6 +42,15 @@ interface TaskGroup {
tasks: DashboardTask[];
}
interface TaskSummary {
completed: number;
nextTask: DashboardTask | null;
paused: number;
scheduled: number;
total: number;
watchers: number;
}
interface TaskCreateFormProps {
rooms: RoomOption[];
onTaskCreate: (input: CreateScheduledTaskInput) => void;
@@ -170,6 +180,23 @@ function taskDisplayName(task: DashboardTask, t: Messages): string {
return task.id;
}
function taskHeadline(task: DashboardTask, t: Messages): string {
if (task.isWatcher && task.ciProvider) {
return `${t.tasks.ciWatch} · ${task.ciProvider}`;
}
return safePreview(task.promptPreview, taskDisplayName(task, t));
}
function taskScheduleText(task: DashboardTask, t: Messages): string {
const scheduleType =
task.scheduleType === 'cron' ||
task.scheduleType === 'interval' ||
task.scheduleType === 'once'
? t.tasks.scheduleTypes[task.scheduleType]
: task.scheduleType;
return `${scheduleType} · ${task.scheduleValue || '-'}`;
}
function isTaskScheduleType(
value: FormDataEntryValue | null,
): value is DashboardTaskScheduleType {
@@ -259,6 +286,102 @@ function buildTaskGroups(tasks: DashboardTask[]): TaskGroup[] {
];
}
function buildTaskSummary(tasks: DashboardTask[]): TaskSummary {
const summary: TaskSummary = {
completed: 0,
nextTask: null,
paused: 0,
scheduled: 0,
total: tasks.length,
watchers: 0,
};
for (const task of tasks) {
const group = taskGroupKey(task);
if (group === 'watchers') summary.watchers += 1;
if (group === 'scheduled') summary.scheduled += 1;
if (group === 'paused') summary.paused += 1;
if (group === 'completed') summary.completed += 1;
if (task.status !== 'active' || !task.nextRun) continue;
const nextTime = new Date(task.nextRun).getTime();
if (Number.isNaN(nextTime)) continue;
const currentTime = summary.nextTask?.nextRun
? new Date(summary.nextTask.nextRun).getTime()
: Number.POSITIVE_INFINITY;
if (nextTime < currentTime) {
summary.nextTask = task;
}
}
return summary;
}
function TaskSummaryMetric({ label, value }: { label: string; value: number }) {
return (
<span className="task-summary-metric">
<strong>{value}</strong>
<small>{label}</small>
</span>
);
}
function TaskBoardSummary({
locale,
summary,
t,
}: {
locale: Locale;
summary: TaskSummary;
t: Messages;
}) {
const nextTask = summary.nextTask;
return (
<section className="task-command-center" aria-label={t.panels.scheduled}>
<div className="task-command-copy">
<span className="eyebrow">{t.panels.scheduled}</span>
<strong>
{summary.total} {t.tasks.count}
</strong>
<p>
{nextTask
? `${t.tasks.next}: ${taskHeadline(nextTask, t)}`
: t.tasks.empty}
</p>
</div>
<div className="task-command-next">
<small>{t.tasks.next}</small>
<strong>
{nextTask ? formatRelativeDate(nextTask.nextRun, locale, t) : '-'}
</strong>
<span>
{nextTask
? `${nextTask.groupFolder} · ${taskScheduleText(nextTask, t)}`
: t.tasks.noTime}
</span>
</div>
<div className="task-command-metrics">
<TaskSummaryMetric
label={t.tasks.groups.scheduled}
value={summary.scheduled}
/>
<TaskSummaryMetric
label={t.tasks.groups.watchers}
value={summary.watchers}
/>
<TaskSummaryMetric
label={t.tasks.groups.paused}
value={summary.paused}
/>
<TaskSummaryMetric
label={t.tasks.groups.completed}
value={summary.completed}
/>
</div>
</section>
);
}
function TaskCreateForm({
rooms,
onTaskCreate,
@@ -280,54 +403,62 @@ function TaskCreateForm({
<div className="task-create-head">
<span className="eyebrow">{t.tasks.createTitle}</span>
<strong>{t.tasks.createSubtitle}</strong>
<p>{t.tasks.scheduleValueHint}</p>
</div>
<div className="task-create-body">
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea
name="prompt"
placeholder={t.tasks.promptPlaceholder}
required
/>
</label>
<div className="task-form-controls">
<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>
<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>
</div>
<div className="task-form-footer">
<span>{t.tasks.schedule}</span>
<button disabled={taskActionKey === 'create'} type="submit">
{taskActionKey === 'create'
? t.tasks.actions.busy
: t.tasks.actions.create}
</button>
</div>
</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>
);
}
@@ -335,10 +466,10 @@ function TaskCreateForm({
function TaskTimeGrid({ locale, t, task }: TaskDateProps) {
return (
<div className="task-time-grid">
<span>
<span className="task-next-run">
<small>{t.tasks.next}</small>
<strong>{formatTaskDate(task.nextRun, locale)}</strong>
<em>{formatRelativeDate(task.nextRun, locale, t)}</em>
<strong>{formatRelativeDate(task.nextRun, locale, t)}</strong>
<em>{formatTaskDate(task.nextRun, locale)}</em>
</span>
<span>
<small>{t.tasks.last}</small>
@@ -347,8 +478,8 @@ function TaskTimeGrid({ locale, t, task }: TaskDateProps) {
</span>
<span>
<small>{t.tasks.schedule}</small>
<strong>{task.scheduleType}</strong>
<em>{task.scheduleValue}</em>
<strong>{taskScheduleText(task, t)}</strong>
<em>{task.contextMode}</em>
</span>
</div>
);
@@ -390,12 +521,23 @@ function TaskPromptDetails({ task, t }: { task: DashboardTask; t: Messages }) {
<summary>{t.tasks.prompt}</summary>
<p>{safePreview(task.promptPreview, t.tasks.emptyPrompt)}</p>
<small>
{task.id} · {task.contextMode} · {task.promptLength} {t.units.chars}
{task.id} · {taskScheduleText(task, t)} · {task.promptLength}{' '}
{t.units.chars}
</small>
</details>
);
}
function TaskMetaStrip({ task, t }: { task: DashboardTask; t: Messages }) {
return (
<div className="task-meta-strip">
<span>{task.groupFolder}</span>
<span>{task.agentType ?? t.tasks.task}</span>
{task.ciProvider ? <span>{task.ciProvider}</span> : null}
</div>
);
}
function TaskEditForm({
onTaskUpdate,
task,
@@ -460,16 +602,14 @@ function TaskCard({
<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>
<span className="task-kind">{taskDisplayName(task, t)}</span>
<strong>{taskHeadline(task, t)}</strong>
<TaskMetaStrip task={task} t={t} />
</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
@@ -546,6 +686,8 @@ function TaskGroupHead({
function TaskGroupSection(props: TaskGroupSectionProps) {
const { group, t } = props;
if (group.tasks.length === 0) return null;
const label = t.tasks.groups[group.key];
const body = <TaskGroupBody {...props} />;
@@ -585,9 +727,11 @@ export function TaskPanel({
t,
}: TaskPanelProps) {
const taskGroups = useMemo(() => buildTaskGroups(tasks), [tasks]);
const taskSummary = useMemo(() => buildTaskSummary(tasks), [tasks]);
return (
<div className="task-board" aria-label={t.tasks.cardsAria}>
<TaskBoardSummary locale={locale} summary={taskSummary} t={t} />
<TaskCreateForm
rooms={rooms}
onTaskCreate={onTaskCreate}
@@ -596,17 +740,19 @@ export function TaskPanel({
/>
{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 className="task-lanes">
{taskGroups.map((group) => (
<TaskGroupSection
group={group}
key={group.key}
locale={locale}
onTaskAction={onTaskAction}
onTaskUpdate={onTaskUpdate}
taskActionKey={taskActionKey}
t={t}
/>
))}
</div>
</div>
);
}

View File

@@ -490,33 +490,33 @@ export const messages = {
noResult: '결과 없음',
noTime: '-',
now: '지금',
createTitle: 'Create task',
createSubtitle: 'Schedule work from web',
room: 'Room',
selectRoom: 'Select room',
scheduleType: 'Type',
scheduleValue: 'Value',
scheduleValueHint: 'ISO time, ms, or cron',
promptPlaceholder: 'What should the agent do?',
editPromptPlaceholder: 'Leave blank to keep prompt',
createTitle: '작업 예약',
createSubtitle: '룸에 반복 작업 걸기',
room: '',
selectRoom: '방 선택',
scheduleType: '방식',
scheduleValue: '시간값',
scheduleValueHint: 'ISO 시간, 10m 같은 간격, 또는 cron',
promptPlaceholder: '에이전트가 실행할 작업을 적어주세요.',
editPromptPlaceholder: '비우면 기존 프롬프트 유지',
scheduleTypes: {
once: 'Once',
interval: 'Interval',
cron: 'Cron',
once: '한 번',
interval: '간격',
cron: '크론',
},
contextModes: {
isolated: 'Isolated',
group: 'Group',
isolated: '격리',
group: '방 맥락',
},
actions: {
create: 'Create',
edit: 'Edit',
save: 'Save',
pause: 'Pause',
resume: 'Resume',
cancel: 'Cancel',
busy: 'Working',
confirmCancel: 'Cancel this scheduled work?',
create: '생성',
edit: '수정',
save: '저장',
pause: '일시정지',
resume: '재개',
cancel: '취소',
busy: '처리 중',
confirmCancel: '이 예약 작업을 취소할까요?',
},
},
status: {

View File

@@ -2439,404 +2439,6 @@ progress::-moz-progress-bar {
background: var(--panel);
}
.task-board {
display: grid;
gap: 12px;
}
.task-create-form,
.task-edit-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
gap: 8px;
align-items: end;
padding: 12px;
border: 1px solid var(--panel-border);
border-radius: 22px;
background: rgba(255, 255, 255, 0.04);
}
.task-edit-form {
grid-template-columns:
minmax(0, 1.4fr) minmax(110px, 0.6fr) minmax(0, 1fr)
auto;
margin-top: 8px;
padding: 9px;
border-radius: 16px;
}
.task-create-head {
align-self: center;
}
.task-create-head strong {
display: block;
margin-top: 2px;
font-size: 16px;
letter-spacing: -0.03em;
}
.task-create-form label,
.task-edit-form label {
display: grid;
min-width: 0;
gap: 4px;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.task-create-form input,
.task-create-form select,
.task-create-form textarea,
.task-edit-form input,
.task-edit-form select,
.task-edit-form textarea {
width: 100%;
min-height: 38px;
border: 1px solid var(--panel-border-strong);
border-radius: 13px;
padding: 8px 10px;
color: var(--ink);
background: rgba(255, 255, 255, 0.04);
font: inherit;
font-size: 13px;
}
.task-create-form textarea,
.task-edit-form textarea {
min-height: 38px;
resize: vertical;
}
.task-create-form input:focus-visible,
.task-create-form select:focus-visible,
.task-create-form textarea:focus-visible,
.task-edit-form input:focus-visible,
.task-edit-form select:focus-visible,
.task-edit-form textarea:focus-visible {
outline: 3px solid rgba(191, 95, 44, 0.18);
}
.task-create-form button,
.task-edit-form button {
min-height: 38px;
border: 0;
border-radius: 999px;
padding: 0 14px;
color: var(--bg);
background: var(--accent);
font: inherit;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.task-create-form button:disabled,
.task-edit-form button:disabled {
cursor: progress;
opacity: 0.55;
}
.task-form-wide {
grid-column: span 2;
}
.task-group {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--panel-border);
border-radius: 22px;
background: rgba(255, 255, 255, 0.02);
}
.task-group-completed {
display: block;
}
.task-group-completed[open] {
display: grid;
}
.task-group-completed summary {
cursor: pointer;
list-style: none;
}
.task-group-completed summary::-webkit-details-marker {
display: none;
}
.task-group-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.task-group-head strong {
display: block;
margin-top: 2px;
font-size: 18px;
letter-spacing: -0.03em;
}
.task-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 360px));
gap: 10px;
}
.task-card {
display: grid;
gap: 9px;
min-width: 0;
padding: 14px;
border: 1px solid var(--panel-border);
border-radius: 14px;
background: var(--bg-card);
}
.task-card-main,
.task-time-grid,
.task-suspended,
.task-result {
min-width: 0;
}
.task-card-main {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
}
.task-title {
min-width: 0;
}
.task-title strong {
display: block;
overflow: hidden;
font-size: 15px;
letter-spacing: -0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-status-line {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.task-provider {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
background: rgba(255, 255, 255, 0.05);
}
.task-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.task-action {
min-height: 40px;
padding: 0 14px;
border: 1px solid var(--panel-border-strong);
border-radius: 999px;
color: var(--ink);
font: inherit;
font-size: 12px;
font-weight: 800;
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
.task-action:hover,
.task-action:focus-visible {
border-color: rgba(191, 95, 44, 0.42);
outline: none;
box-shadow: 0 0 0 3px rgba(191, 95, 44, 0.12);
}
.task-action:disabled {
cursor: progress;
opacity: 0.55;
}
.task-action.is-busy {
display: inline-flex;
gap: 7px;
align-items: center;
justify-content: center;
cursor: progress;
opacity: 0.85;
background: rgba(191, 95, 44, 0.12);
border-color: rgba(191, 95, 44, 0.32);
}
.task-action.is-busy::before {
content: '';
display: block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ej-spin 0.7s linear infinite;
}
@keyframes ej-spin {
to {
transform: rotate(360deg);
}
}
.task-action-cancel {
color: #8f2f22;
border-color: rgba(183, 71, 52, 0.22);
background: rgba(183, 71, 52, 0.08);
}
.task-time-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.task-time-grid > span,
.task-suspended,
.task-result {
padding: 9px;
border: 1px solid var(--panel-border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.025);
}
.task-time-grid small,
.task-suspended span,
.task-result span,
.task-time-grid em,
.task-suspended em {
color: var(--muted);
font-size: 11px;
font-style: normal;
font-weight: 600;
}
.task-time-grid small,
.task-time-grid strong,
.task-time-grid em,
.task-suspended span,
.task-suspended strong,
.task-suspended em,
.task-result span,
.task-result strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-time-grid strong,
.task-suspended strong,
.task-result strong {
margin-top: 3px;
}
.task-suspended {
border-color: rgba(181, 138, 34, 0.22);
background: rgba(181, 138, 34, 0.08);
}
.task-result {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: center;
border-color: rgba(63, 127, 81, 0.15);
}
.result-fail {
border-color: rgba(183, 71, 52, 0.28);
background: rgba(183, 71, 52, 0.08);
}
.result-none {
border-color: rgba(109, 115, 95, 0.14);
}
.task-prompt {
color: var(--muted);
}
.task-edit summary,
.task-prompt summary {
min-height: 32px;
color: var(--accent-strong);
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.task-prompt p {
margin: 4px 0;
overflow-wrap: anywhere;
}
.task-prompt small {
display: block;
overflow: hidden;
font-family:
'SF Mono', 'Cascadia Code', 'Roboto Mono', ui-monospace, monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-group-empty {
padding: 12px;
border-radius: 14px;
color: var(--muted);
background: rgba(255, 255, 255, 0.025);
}
.pill-watchers {
color: #173e23;
background: rgba(63, 127, 81, 0.18);
}
.pill-scheduled {
color: #6e4a05;
background: rgba(181, 138, 34, 0.16);
}
.pill-paused {
color: #6e4a05;
background: rgba(181, 138, 34, 0.2);
}
.pill-completed {
color: #625d52;
background: rgba(109, 115, 95, 0.16);
}
.task-card > p {
overflow-wrap: anywhere;
}
.mono-chip {
font-family:
'SF Mono', 'Cascadia Code', 'Roboto Mono', ui-monospace, monospace;
@@ -3165,55 +2767,6 @@ progress::-moz-progress-bar {
.health-service > div:nth-of-type(3) {
grid-column: 1 / -1;
}
.task-group {
padding: 10px;
}
.task-create-form,
.task-edit-form {
grid-template-columns: 1fr;
gap: 7px;
padding: 10px;
}
.task-form-wide {
grid-column: auto;
}
.task-list {
grid-template-columns: 1fr;
gap: 8px;
}
.task-card {
gap: 7px;
padding: 9px;
}
.task-action {
min-height: 44px;
padding-inline: 12px;
}
.task-card-main {
gap: 7px;
}
.task-time-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.task-time-grid > span,
.task-suspended,
.task-result {
padding: 7px;
}
.task-prompt summary {
min-height: 28px;
}
}
@media (max-width: 380px) {
@@ -3270,14 +2823,6 @@ progress::-moz-progress-bar {
.health-restart-record {
grid-template-columns: 1fr;
}
.task-time-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.task-time-grid > span:last-child {
grid-column: auto;
}
}
@media (max-width: 420px) {

View File

@@ -162,6 +162,8 @@ async function main() {
},
);
await runScheduledBoardScenario(browser, baseUrl);
await runScenario(
'health route redirects to rooms and degraded state is conditional',
browser,
@@ -196,6 +198,29 @@ async function main() {
}
}
async function runScheduledBoardScenario(browser: Browser, baseUrl: string) {
await runScenario(
'scheduled board surfaces next task without empty lanes',
browser,
baseUrl,
async (page) => {
await page.goto(new URL('/#/scheduled', baseUrl).toString(), {
waitUntil: 'networkidle',
});
await assertVisible(page.locator('#scheduled .task-command-center'));
await assertVisible(page.locator('#scheduled .task-create-form'));
await assertVisible(page.getByText('Nightly cleanup').first());
await assertVisible(page.getByText('*/15 * * * *').first());
assert.equal(await page.locator('#scheduled .task-card').count(), 3);
assert.equal(
await page.locator('#scheduled .task-group-empty').count(),
0,
);
},
);
}
async function startDashboardServer(): Promise<ViteDevServer> {
const port = Number(process.env.DASHBOARD_UX_PORT ?? 5175);
const server = await createServer({
@@ -306,7 +331,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
}
if (method === 'GET' && url.pathname === '/api/tasks') {
await fulfillJson(route, []);
await fulfillJson(route, mockTasks());
return;
}
@@ -532,6 +557,52 @@ function mockStatusSnapshot() {
};
}
function mockTask(overrides: Record<string, unknown>) {
return {
agentType: 'codex',
chatJid: 'room@example',
ciMetadata: null,
ciProvider: null,
contextMode: 'group',
createdAt: MOCK_TIME,
groupFolder: 'ejclaw',
id: 'task-fixture',
isWatcher: false,
lastResult: 'ok',
lastRun: MOCK_TIME,
nextRun: new Date(Date.now() + 30 * 60_000).toISOString(),
promptLength: 28,
promptPreview: 'Nightly cleanup',
scheduleType: 'cron',
scheduleValue: '*/15 * * * *',
status: 'active',
suspendedUntil: null,
...overrides,
};
}
function mockTasks() {
return [
mockTask({ id: 'task-nightly-cleanup' }),
mockTask({
ciProvider: 'github',
id: 'task-ci-watch',
isWatcher: true,
promptPreview: 'Watch PR #133',
scheduleType: 'interval',
scheduleValue: '5m',
}),
mockTask({
id: 'task-paused-report',
nextRun: null,
promptPreview: 'Weekly report',
scheduleValue: '0 9 * * 1',
status: 'paused',
suspendedUntil: new Date(Date.now() + 2 * 3_600_000).toISOString(),
}),
];
}
function runtimeSkill(name: string, description: string, skillPath: string) {
return { name, description, path: skillPath };
}