Spaces:
Running
Running
Agent Manager commited on
Commit ·
4a7d688
1
Parent(s): a96dad4
Keep cron table columns intrinsic
Browse files- web/src/components/CronSettings.tsx +3 -2
- web/src/styles.css +7 -7
- web/test/cronSettings.test.mjs +43 -12
web/src/components/CronSettings.tsx
CHANGED
|
@@ -281,9 +281,9 @@ export default function CronSettings({ clis }: { clis: Cli[] }) {
|
|
| 281 |
<colgroup>
|
| 282 |
<col className="cron-col-job" /><col className="cron-col-agent" /><col className="cron-col-type" />
|
| 283 |
<col className="cron-col-interval" /><col className="cron-col-state" /><col className="cron-col-next" />
|
| 284 |
-
<col className="cron-col-last" /><col className="cron-col-actions" />
|
| 285 |
</colgroup>
|
| 286 |
-
<thead><tr><th>Job</th><th>Agent</th><th>Type</th><th>Interval</th><th>State</th><th>Next</th><th>Last run</th><th>Actions</th></tr></thead>
|
| 287 |
<tbody>{jobs.map((job) => {
|
| 288 |
const type = clis.find((candidate) => candidate.id === job.agent.cli)?.label || job.agent.cli;
|
| 289 |
const lastTitle = job.last
|
|
@@ -310,6 +310,7 @@ export default function CronSettings({ clis }: { clis: Cli[] }) {
|
|
| 310 |
if (editingId === job.id) reset();
|
| 311 |
})}>Delete</button>
|
| 312 |
</span></td>
|
|
|
|
| 313 |
</tr>
|
| 314 |
);
|
| 315 |
})}</tbody>
|
|
|
|
| 281 |
<colgroup>
|
| 282 |
<col className="cron-col-job" /><col className="cron-col-agent" /><col className="cron-col-type" />
|
| 283 |
<col className="cron-col-interval" /><col className="cron-col-state" /><col className="cron-col-next" />
|
| 284 |
+
<col className="cron-col-last" /><col className="cron-col-actions" /><col className="cron-col-fill" />
|
| 285 |
</colgroup>
|
| 286 |
+
<thead><tr><th>Job</th><th>Agent</th><th>Type</th><th>Interval</th><th>State</th><th>Next</th><th>Last run</th><th>Actions</th><th className="cron-fill" aria-hidden="true" /></tr></thead>
|
| 287 |
<tbody>{jobs.map((job) => {
|
| 288 |
const type = clis.find((candidate) => candidate.id === job.agent.cli)?.label || job.agent.cli;
|
| 289 |
const lastTitle = job.last
|
|
|
|
| 310 |
if (editingId === job.id) reset();
|
| 311 |
})}>Delete</button>
|
| 312 |
</span></td>
|
| 313 |
+
<td className="cron-fill" aria-hidden="true" />
|
| 314 |
</tr>
|
| 315 |
);
|
| 316 |
})}</tbody>
|
web/src/styles.css
CHANGED
|
@@ -1433,15 +1433,15 @@ a.btn-ghost { text-decoration: none; }
|
|
| 1433 |
.cron-page h3 { margin-top: 24px; }
|
| 1434 |
.cron-table-wrap { margin-top: 8px; border-top: 1px solid var(--border); }
|
| 1435 |
.cron-table { width: 100%; border-collapse: collapse; table-layout: auto; font-size: 11.5px; }
|
| 1436 |
-
/*
|
| 1437 |
-
|
| 1438 |
-
|
| 1439 |
-
.cron-col-job, .cron-col-agent
|
| 1440 |
-
.cron-col-
|
| 1441 |
-
.cron-col-
|
| 1442 |
.cron-table th { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--muted); font: 500 10px var(--font-mono); letter-spacing: 0.045em; text-align: left; text-transform: uppercase; white-space: nowrap; }
|
| 1443 |
.cron-table td { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--text); font-family: var(--font-mono); vertical-align: middle; white-space: nowrap; }
|
| 1444 |
-
.cron-table
|
| 1445 |
.cron-table tbody tr { cursor: pointer; }
|
| 1446 |
.cron-table tbody tr:hover, .cron-table tbody tr.editing { background: var(--panel-2); }
|
| 1447 |
.cron-table tbody tr:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
|
|
|
| 1433 |
.cron-page h3 { margin-top: 24px; }
|
| 1434 |
.cron-table-wrap { margin-top: 8px; border-top: 1px solid var(--border); }
|
| 1435 |
.cron-table { width: 100%; border-collapse: collapse; table-layout: auto; font-size: 11.5px; }
|
| 1436 |
+
/* Every data column takes its intrinsic one-line width. The final empty column
|
| 1437 |
+
owns all surplus, keeping the useful columns grouped instead of stretching
|
| 1438 |
+
Job and Agent across the table. */
|
| 1439 |
+
.cron-col-job, .cron-col-agent, .cron-col-type, .cron-col-interval,
|
| 1440 |
+
.cron-col-state, .cron-col-next, .cron-col-last, .cron-col-actions { width: 1%; }
|
| 1441 |
+
.cron-col-fill { width: auto; }
|
| 1442 |
.cron-table th { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--muted); font: 500 10px var(--font-mono); letter-spacing: 0.045em; text-align: left; text-transform: uppercase; white-space: nowrap; }
|
| 1443 |
.cron-table td { padding: 7px 5px; border-bottom: 1px solid var(--border); color: var(--text); font-family: var(--font-mono); vertical-align: middle; white-space: nowrap; }
|
| 1444 |
+
.cron-table .cron-fill { padding: 0; }
|
| 1445 |
.cron-table tbody tr { cursor: pointer; }
|
| 1446 |
.cron-table tbody tr:hover, .cron-table tbody tr.editing { background: var(--panel-2); }
|
| 1447 |
.cron-table tbody tr:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
|
web/test/cronSettings.test.mjs
CHANGED
|
@@ -17,7 +17,7 @@ const stub = path.join(tmp, 'api-stub.ts');
|
|
| 17 |
fs.writeFileSync(stub, `
|
| 18 |
export * from ${JSON.stringify(path.join(WEB, 'src/api.ts'))};
|
| 19 |
let jobs = [{
|
| 20 |
-
id: 'cron_one', name: 'morning check
|
| 21 |
schedule: { cron: '0 9 * * *', tz: 'Europe/Zurich' }, runOnRestart: true,
|
| 22 |
state: 'running', createdAt: '2026-08-19T00:00:00Z', updatedAt: '2026-08-19T00:00:00Z',
|
| 23 |
next: '2026-08-20T07:00:00Z', last: { at: '2026-08-19T07:00:00Z', status: 'ok', durationMs: 258 },
|
|
@@ -118,6 +118,21 @@ try {
|
|
| 118 |
assert.equal(compactRow.buttonsFit, true, 'action labels are not clipped');
|
| 119 |
assert.deepEqual(compactRow.buttonBorders, ['solid', 'solid', 'solid'], 'actions use button controls, not text links');
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
const widths = [];
|
| 122 |
for (const width of [1200, 980, 760, 390]) {
|
| 123 |
await page.setViewportSize({ width, height: 900 });
|
|
@@ -129,22 +144,38 @@ try {
|
|
| 129 |
width: window.innerWidth,
|
| 130 |
overflow: wrap.scrollWidth - wrap.clientWidth,
|
| 131 |
clipped: cells.map((cell) => cell.scrollWidth > cell.clientWidth),
|
|
|
|
|
|
|
| 132 |
actionFits: buttons.every((button) => button.scrollWidth <= button.clientWidth)
|
| 133 |
&& cells[7].scrollWidth <= cells[7].clientWidth,
|
| 134 |
};
|
| 135 |
}));
|
| 136 |
}
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
}
|
| 140 |
for (const layout of widths) {
|
| 141 |
-
assert.deepEqual(layout.clipped.slice(
|
| 142 |
-
`${layout.width}px
|
|
|
|
| 143 |
assert.equal(layout.actionFits, true, `${layout.width}px keeps every action usable`);
|
| 144 |
}
|
| 145 |
-
assert.equal(widths.at(-1).overflow > 0, true, 'the genuinely narrow table
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
await page.getByLabel('Job name', { exact: true }).fill('weekday digest');
|
| 150 |
await page.getByLabel('Agent name', { exact: true }).fill('digest-agent');
|
|
@@ -164,8 +195,8 @@ try {
|
|
| 164 |
const row = page.locator('.cron-table tbody tr').filter({ hasText: 'morning check' });
|
| 165 |
await row.click();
|
| 166 |
await page.getByRole('button', { name: 'Update job' }).waitFor();
|
| 167 |
-
assert.equal(await page.getByLabel('Job name', { exact: true }).inputValue(), 'morning check
|
| 168 |
-
assert.equal(await page.getByLabel('Agent name', { exact: true }).inputValue(), 'triage
|
| 169 |
assert.equal(await page.getByLabel('Prompt', { exact: true }).inputValue(), 'Check.');
|
| 170 |
assert.equal(await page.locator('input[type="time"]').inputValue(), '09:00');
|
| 171 |
assert.equal(await page.getByLabel('timezone').inputValue(), 'Europe/Zurich');
|
|
@@ -185,7 +216,7 @@ try {
|
|
| 185 |
await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'update' && call[2].prompt));
|
| 186 |
const edited = await page.evaluate(() => window.__cronCalls.find((call) => call[0] === 'update' && call[2].prompt));
|
| 187 |
assert.deepEqual(edited, ['update', 'cron_one', {
|
| 188 |
-
name: 'morning check
|
| 189 |
schedule: { cron: '15 10 * * 4', tz: 'Asia/Tokyo' }, runOnRestart: false,
|
| 190 |
}], 'row selection round-trips every persisted field through PUT');
|
| 191 |
assert.equal(await page.getByRole('button', { name: 'Create job' }).isVisible(), true, 'successful update returns the form to create mode');
|
|
|
|
| 17 |
fs.writeFileSync(stub, `
|
| 18 |
export * from ${JSON.stringify(path.join(WEB, 'src/api.ts'))};
|
| 19 |
let jobs = [{
|
| 20 |
+
id: 'cron_one', name: 'morning check', agent: { name: 'triage', cli: 'codex' }, prompt: 'Check.',
|
| 21 |
schedule: { cron: '0 9 * * *', tz: 'Europe/Zurich' }, runOnRestart: true,
|
| 22 |
state: 'running', createdAt: '2026-08-19T00:00:00Z', updatedAt: '2026-08-19T00:00:00Z',
|
| 23 |
next: '2026-08-20T07:00:00Z', last: { at: '2026-08-19T07:00:00Z', status: 'ok', durationMs: 258 },
|
|
|
|
| 118 |
assert.equal(compactRow.buttonsFit, true, 'action labels are not clipped');
|
| 119 |
assert.deepEqual(compactRow.buttonBorders, ['solid', 'solid', 'solid'], 'actions use button controls, not text links');
|
| 120 |
|
| 121 |
+
await page.setViewportSize({ width: 1200, height: 900 });
|
| 122 |
+
const contentFit = await firstRow.evaluate((row) => {
|
| 123 |
+
const cells = [...row.querySelectorAll('td')];
|
| 124 |
+
return cells.slice(0, 2).map((cell) => {
|
| 125 |
+
const range = document.createRange();
|
| 126 |
+
range.selectNodeContents(cell);
|
| 127 |
+
const textWidth = range.getBoundingClientRect().width;
|
| 128 |
+
const style = getComputedStyle(cell);
|
| 129 |
+
return cell.getBoundingClientRect().width - textWidth
|
| 130 |
+
- parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
| 131 |
+
});
|
| 132 |
+
});
|
| 133 |
+
assert.equal(contentFit.every((slack) => Math.abs(slack) < 1), true,
|
| 134 |
+
`wide Job and Agent columns hug their content (${contentFit.join(', ')}px surplus)`);
|
| 135 |
+
|
| 136 |
const widths = [];
|
| 137 |
for (const width of [1200, 980, 760, 390]) {
|
| 138 |
await page.setViewportSize({ width, height: 900 });
|
|
|
|
| 144 |
width: window.innerWidth,
|
| 145 |
overflow: wrap.scrollWidth - wrap.clientWidth,
|
| 146 |
clipped: cells.map((cell) => cell.scrollWidth > cell.clientWidth),
|
| 147 |
+
ellipsis: cells.slice(0, 8).some((cell) => getComputedStyle(cell).textOverflow === 'ellipsis'),
|
| 148 |
+
filler: cells[8].getBoundingClientRect().width,
|
| 149 |
actionFits: buttons.every((button) => button.scrollWidth <= button.clientWidth)
|
| 150 |
&& cells[7].scrollWidth <= cells[7].clientWidth,
|
| 151 |
};
|
| 152 |
}));
|
| 153 |
}
|
| 154 |
+
assert.equal(widths[0].overflow, 0, 'the wide table needs no scrollbar');
|
| 155 |
+
assert.equal(widths[0].filler > 0, true, 'wide surplus lands in the empty column after Actions');
|
|
|
|
| 156 |
for (const layout of widths) {
|
| 157 |
+
assert.deepEqual(layout.clipped.slice(0, 8), [false, false, false, false, false, false, false, false],
|
| 158 |
+
`${layout.width}px truncates no data column`);
|
| 159 |
+
assert.equal(layout.ellipsis, false, `${layout.width}px applies no ellipsis treatment`);
|
| 160 |
assert.equal(layout.actionFits, true, `${layout.width}px keeps every action usable`);
|
| 161 |
}
|
| 162 |
+
assert.equal(widths.at(-1).overflow > 0, true, 'the genuinely narrow table scrolls rather than truncating');
|
| 163 |
+
const synchronizedScroll = await firstRow.evaluate((row) => {
|
| 164 |
+
const wrap = row.closest('.cron-table-wrap');
|
| 165 |
+
const header = row.closest('table').querySelector('th');
|
| 166 |
+
const cell = row.querySelector('td');
|
| 167 |
+
const before = { header: header.getBoundingClientRect().left, cell: cell.getBoundingClientRect().left };
|
| 168 |
+
wrap.scrollLeft = wrap.scrollWidth;
|
| 169 |
+
const after = { header: header.getBoundingClientRect().left, cell: cell.getBoundingClientRect().left };
|
| 170 |
+
return {
|
| 171 |
+
moved: before.header - after.header,
|
| 172 |
+
aligned: Math.abs((before.header - before.cell) - (after.header - after.cell)),
|
| 173 |
+
scrollLeft: wrap.scrollLeft,
|
| 174 |
+
};
|
| 175 |
+
});
|
| 176 |
+
assert.equal(synchronizedScroll.scrollLeft > 0, true, 'phone table can scroll to the Actions column');
|
| 177 |
+
assert.equal(synchronizedScroll.moved > 0, true, 'the header moves with horizontal scrolling');
|
| 178 |
+
assert.equal(synchronizedScroll.aligned < 0.5, true, 'header and body columns stay aligned while scrolling');
|
| 179 |
|
| 180 |
await page.getByLabel('Job name', { exact: true }).fill('weekday digest');
|
| 181 |
await page.getByLabel('Agent name', { exact: true }).fill('digest-agent');
|
|
|
|
| 195 |
const row = page.locator('.cron-table tbody tr').filter({ hasText: 'morning check' });
|
| 196 |
await row.click();
|
| 197 |
await page.getByRole('button', { name: 'Update job' }).waitFor();
|
| 198 |
+
assert.equal(await page.getByLabel('Job name', { exact: true }).inputValue(), 'morning check');
|
| 199 |
+
assert.equal(await page.getByLabel('Agent name', { exact: true }).inputValue(), 'triage');
|
| 200 |
assert.equal(await page.getByLabel('Prompt', { exact: true }).inputValue(), 'Check.');
|
| 201 |
assert.equal(await page.locator('input[type="time"]').inputValue(), '09:00');
|
| 202 |
assert.equal(await page.getByLabel('timezone').inputValue(), 'Europe/Zurich');
|
|
|
|
| 216 |
await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'update' && call[2].prompt));
|
| 217 |
const edited = await page.evaluate(() => window.__cronCalls.find((call) => call[0] === 'update' && call[2].prompt));
|
| 218 |
assert.deepEqual(edited, ['update', 'cron_one', {
|
| 219 |
+
name: 'morning check', agent: { name: 'triage', cli: 'codex' }, prompt: 'Check and summarize.',
|
| 220 |
schedule: { cron: '15 10 * * 4', tz: 'Asia/Tokyo' }, runOnRestart: false,
|
| 221 |
}], 'row selection round-trips every persisted field through PUT');
|
| 222 |
assert.equal(await page.getByRole('button', { name: 'Create job' }).isVisible(), true, 'successful update returns the form to create mode');
|