Agent Manager commited on
Commit
4a7d688
·
1 Parent(s): a96dad4

Keep cron table columns intrinsic

Browse files
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
- /* 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; }
 
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 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 },
@@ -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
- 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');
@@ -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 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,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 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');
 
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');