Agent Manager commited on
Commit
a96dad4
·
1 Parent(s): 9ef3ac2

Make cron table columns content-sized

Browse files
web/src/components/CronSettings.tsx CHANGED
@@ -73,11 +73,7 @@ const when = (iso: string | null, zone: string, now: number) => {
73
  const delta = value - now;
74
  if (delta > 0 && delta < 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 60_000))}m`;
75
  if (delta > 0 && delta < 24 * 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 3_600_000))}h`;
76
- try {
77
- return new Intl.DateTimeFormat(undefined, {
78
- timeZone: zone, weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
79
- }).format(new Date(value));
80
- } catch { return new Date(value).toLocaleString(); }
81
  };
82
  const compactWhen = (iso: string, zone: string) => {
83
  const value = Date.parse(iso);
@@ -290,19 +286,22 @@ export default function CronSettings({ clis }: { clis: Cli[] }) {
290
  <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>
291
  <tbody>{jobs.map((job) => {
292
  const type = clis.find((candidate) => candidate.id === job.agent.cli)?.label || job.agent.cli;
 
 
 
293
  return (
294
  <tr
295
  key={job.id} className={editingId === job.id ? 'editing' : ''} tabIndex={0}
296
  aria-selected={editingId === job.id}
297
  onClick={() => edit(job)} onKeyDown={(event) => editFromKeyboard(event, job)}
298
  >
299
- <td title={job.name}>{job.name}</td>
300
- <td title={job.agent.name}>{job.agent.name}</td>
301
  <td className="cron-type" aria-label={type} title={type}><Logo cli={job.agent.cli} size={14} /></td>
302
  <td>{intervalName(job)}</td>
303
  <td className={`cron-state ${job.state}`}>{job.state}</td>
304
  <td className="cron-dim">{when(job.next, job.schedule.tz, now)}</td>
305
- <td title={job.last?.error || ''}>{job.last ? <><span className={`cron-last ${job.last.status}`}>{job.last.status}</span> <span className="cron-dim">{duration(job.last.durationMs)} · {compactWhen(job.last.at, job.schedule.tz)}</span></> : <span className="cron-dim">—</span>}</td>
306
  <td onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}><span className="cron-actions">
307
  <button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.runCron(job.id))}>Run now</button>
308
  <button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.updateCron(job.id, { state: job.state === 'running' ? 'stopped' : 'running' }))}>{job.state === 'running' ? 'Stop' : 'Start'}</button>
 
73
  const delta = value - now;
74
  if (delta > 0 && delta < 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 60_000))}m`;
75
  if (delta > 0 && delta < 24 * 60 * 60_000) return `in ${Math.max(1, Math.round(delta / 3_600_000))}h`;
76
+ return compactWhen(iso, zone);
 
 
 
 
77
  };
78
  const compactWhen = (iso: string, zone: string) => {
79
  const value = Date.parse(iso);
 
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
290
+ ? [duration(job.last.durationMs), job.last.error].filter(Boolean).join(' · ')
291
+ : '';
292
  return (
293
  <tr
294
  key={job.id} className={editingId === job.id ? 'editing' : ''} tabIndex={0}
295
  aria-selected={editingId === job.id}
296
  onClick={() => edit(job)} onKeyDown={(event) => editFromKeyboard(event, job)}
297
  >
298
+ <td className="cron-name" title={job.name}>{job.name}</td>
299
+ <td className="cron-name" title={job.agent.name}>{job.agent.name}</td>
300
  <td className="cron-type" aria-label={type} title={type}><Logo cli={job.agent.cli} size={14} /></td>
301
  <td>{intervalName(job)}</td>
302
  <td className={`cron-state ${job.state}`}>{job.state}</td>
303
  <td className="cron-dim">{when(job.next, job.schedule.tz, now)}</td>
304
+ <td title={lastTitle}>{job.last ? <><span className={`cron-last ${job.last.status}`}>{job.last.status}</span> <span className="cron-dim">· {compactWhen(job.last.at, job.schedule.tz)}</span></> : <span className="cron-dim">—</span>}</td>
305
  <td onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}><span className="cron-actions">
306
  <button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.runCron(job.id))}>Run now</button>
307
  <button className="btn-ghost" disabled={busy === job.id} onClick={() => act(job.id, () => api.updateCron(job.id, { state: job.state === 'running' ? 'stopped' : 'running' }))}>{job.state === 'running' ? 'Stop' : 'Start'}</button>
web/src/styles.css CHANGED
@@ -1432,16 +1432,16 @@ a.btn-ghost { text-decoration: none; }
1432
  .cron-message { margin-top: 14px; }
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%; min-width: 900px; border-collapse: collapse; table-layout: fixed; font-size: 11.5px; }
1436
- .cron-col-job, .cron-col-agent { width: auto; }
1437
- .cron-col-type { width: 42px; }
1438
- .cron-col-interval { width: 130px; }
1439
- .cron-col-state { width: 62px; }
1440
- .cron-col-next { width: 82px; }
1441
- .cron-col-last { width: 158px; }
1442
- .cron-col-actions { width: 174px; }
1443
- .cron-table th { padding: 7px 8px; 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; }
1444
- .cron-table td { padding: 7px 8px; border-bottom: 1px solid var(--border); overflow: hidden; color: var(--text); font-family: var(--font-mono); text-overflow: ellipsis; vertical-align: middle; white-space: nowrap; }
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; }
@@ -1452,7 +1452,7 @@ a.btn-ghost { text-decoration: none; }
1452
  .cron-state.stopped { color: var(--muted); }
1453
  .cron-last.failed { color: var(--danger); }
1454
  .cron-actions { display: inline-flex; gap: 4px; white-space: nowrap; }
1455
- .cron-actions .btn-ghost { padding: 4px 6px; border-radius: var(--r-sm); background: var(--panel); font: 500 10.5px var(--font-mono); }
1456
  .cron-actions .btn-ghost:disabled { cursor: wait; }
1457
 
1458
  @container (max-width: 560px) {
 
1432
  .cron-message { margin-top: 14px; }
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
+ /* The two unbounded names share whatever is left. Every other column takes its
1437
+ intrinsic, one-line width, so state/date/action labels cannot be sacrificed
1438
+ to a long name or to widths tuned for one particular viewport. */
1439
+ .cron-col-job, .cron-col-agent { width: 50%; }
1440
+ .cron-col-type, .cron-col-interval, .cron-col-state, .cron-col-next,
1441
+ .cron-col-last, .cron-col-actions { width: 1%; }
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 td.cron-name { max-width: 0; overflow: hidden; text-overflow: ellipsis; }
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; }
 
1452
  .cron-state.stopped { color: var(--muted); }
1453
  .cron-last.failed { color: var(--danger); }
1454
  .cron-actions { display: inline-flex; gap: 4px; white-space: nowrap; }
1455
+ .cron-actions .btn-ghost { padding: 4px 5px; border-radius: var(--r-sm); background: var(--panel); font: 500 10.5px var(--font-mono); }
1456
  .cron-actions .btn-ghost:disabled { cursor: wait; }
1457
 
1458
  @container (max-width: 560px) {
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', 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 },
@@ -114,10 +114,38 @@ try {
114
  assert.equal(compactRow.noWrap, true, 'every retained column is pinned to one line');
115
  assert.equal(compactRow.typeText, '', 'type column is icon-only');
116
  assert.equal(compactRow.interval, 'every day 09:00', 'interval omits timezone and restart detail');
117
- assert.match(compactRow.last, /^ok 258ms · 19 Aug 09:00$/, 'last run uses the dense date');
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.getByLabel('Job name', { exact: true }).fill('weekday digest');
122
  await page.getByLabel('Agent name', { exact: true }).fill('digest-agent');
123
  await page.getByLabel('Prompt', { exact: true }).fill('Summarize yesterday.');
@@ -136,8 +164,8 @@ try {
136
  const row = page.locator('.cron-table tbody tr').filter({ hasText: 'morning check' });
137
  await row.click();
138
  await page.getByRole('button', { name: 'Update job' }).waitFor();
139
- assert.equal(await page.getByLabel('Job name', { exact: true }).inputValue(), 'morning check');
140
- assert.equal(await page.getByLabel('Agent name', { exact: true }).inputValue(), 'triage');
141
  assert.equal(await page.getByLabel('Prompt', { exact: true }).inputValue(), 'Check.');
142
  assert.equal(await page.locator('input[type="time"]').inputValue(), '09:00');
143
  assert.equal(await page.getByLabel('timezone').inputValue(), 'Europe/Zurich');
@@ -157,7 +185,7 @@ try {
157
  await page.waitForFunction(() => window.__cronCalls.some((call) => call[0] === 'update' && call[2].prompt));
158
  const edited = await page.evaluate(() => window.__cronCalls.find((call) => call[0] === 'update' && call[2].prompt));
159
  assert.deepEqual(edited, ['update', 'cron_one', {
160
- name: 'morning check', agent: { name: 'triage', cli: 'codex' }, prompt: 'Check and summarize.',
161
  schedule: { cron: '15 10 * * 4', tz: 'Asia/Tokyo' }, runOnRestart: false,
162
  }], 'row selection round-trips every persisted field through PUT');
163
  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 with a deliberately long job name', agent: { name: 'triage agent with a deliberately long name', 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 },
 
114
  assert.equal(compactRow.noWrap, true, 'every retained column is pinned to one line');
115
  assert.equal(compactRow.typeText, '', 'type column is icon-only');
116
  assert.equal(compactRow.interval, 'every day 09:00', 'interval omits timezone and restart detail');
117
+ assert.match(compactRow.last, /^ok · 19 Aug 09:00$/, 'last run keeps the complete dense date');
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 });
124
+ widths.push(await firstRow.evaluate((row) => {
125
+ const wrap = row.closest('.cron-table-wrap');
126
+ const cells = [...row.querySelectorAll('td')];
127
+ const buttons = [...row.querySelectorAll('button')];
128
+ return {
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
+ for (const layout of widths.slice(0, 2)) {
138
+ assert.equal(layout.overflow, 0, `${layout.width}px viewport needs no table scrollbar`);
139
+ }
140
+ for (const layout of widths) {
141
+ assert.deepEqual(layout.clipped.slice(2), [false, false, false, false, false, false],
142
+ `${layout.width}px keeps every bounded column intact`);
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 owns its overflow');
146
+ assert.equal(widths.at(-1).clipped.slice(0, 2).some(Boolean), true,
147
+ 'at the narrow limit a name gives before a bounded column or action');
148
+
149
  await page.getByLabel('Job name', { exact: true }).fill('weekday digest');
150
  await page.getByLabel('Agent name', { exact: true }).fill('digest-agent');
151
  await page.getByLabel('Prompt', { exact: true }).fill('Summarize yesterday.');
 
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 with a deliberately long job name');
168
+ assert.equal(await page.getByLabel('Agent name', { exact: true }).inputValue(), 'triage agent with a deliberately long name');
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
  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 with a deliberately long job name', agent: { name: 'triage agent with a deliberately long name', cli: 'codex' }, prompt: 'Check and summarize.',
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');