diff --git a/backend/src/db/migrations/024-schedule-runs-viewed-at.ts b/backend/src/db/migrations/024-schedule-runs-viewed-at.ts new file mode 100644 index 000000000..b9e39c9b5 --- /dev/null +++ b/backend/src/db/migrations/024-schedule-runs-viewed-at.ts @@ -0,0 +1,36 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string +} + +const migration: Migration = { + version: 24, + name: 'schedule-runs-viewed-at', + + up(db) { + const columns = db.prepare('PRAGMA table_info(schedule_runs)').all() as ColumnInfo[] + if (!columns.some((column) => column.name === 'viewed_at')) { + db.run('ALTER TABLE schedule_runs ADD COLUMN viewed_at INTEGER') + } + + db.run(` + UPDATE schedule_runs + SET viewed_at = COALESCE(finished_at, started_at) + WHERE viewed_at IS NULL AND status != 'running' + `) + + db.run(` + CREATE INDEX IF NOT EXISTS idx_schedule_runs_unread + ON schedule_runs(finished_at DESC) + WHERE viewed_at IS NULL + `) + }, + + down(db) { + db.run('DROP INDEX IF EXISTS idx_schedule_runs_unread') + db.run('ALTER TABLE schedule_runs DROP COLUMN viewed_at') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 83fca911b..b0b66cd14 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -22,6 +22,7 @@ import migration020 from './020-drop-opencode-model-state' import migration021 from './021-drop-schedule-run-workspace-id' import migration022 from './022-schedule-runs-session-index' import migration023 from './023-schedule-mcp-servers' +import migration024 from './024-schedule-runs-viewed-at' export const allMigrations: Migration[] = [ migration001, @@ -47,4 +48,5 @@ export const allMigrations: Migration[] = [ migration021, migration022, migration023, + migration024, ] diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index 6bf70b007..b9c34da24 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -47,6 +47,7 @@ interface ScheduleRunRow { status: string started_at: number finished_at: number | null + viewed_at: number | null created_at: number session_id: string | null session_title: string | null @@ -133,6 +134,7 @@ function rowToScheduleRun(row: ScheduleRunRow): ScheduleRun { status: row.status, startedAt: row.started_at, finishedAt: row.finished_at, + viewedAt: row.viewed_at, createdAt: row.created_at, sessionId: row.session_id, sessionTitle: row.session_title, @@ -522,6 +524,7 @@ export function listScheduleRunsByJob(db: Database, repoId: number, jobId: numbe status, started_at, finished_at, + viewed_at, created_at, session_id, session_title, @@ -540,10 +543,20 @@ export function listScheduleRunsByJob(db: Database, repoId: number, jobId: numbe return rows.map(rowToScheduleRun) } +export interface ScheduleRunSummary { + id: number + status: ScheduleRunStatus + startedAt: number + finishedAt: number | null + viewedAt: number | null + preview: string | null +} + export interface ScheduleJobWithRepo extends ScheduleJob { repoName: string repoPath: string repoUrl: string + lastRun: ScheduleRunSummary | null } interface ScheduleJobWithRepoRow extends ScheduleJobRow { @@ -551,6 +564,13 @@ interface ScheduleJobWithRepoRow extends ScheduleJobRow { repo_path: string | null repo_name: string | null repo_source_path: string | null + last_run_id: number | null + last_run_status: string | null + last_run_started_at: number | null + last_run_finished_at: number | null + last_run_viewed_at: number | null + last_run_error_text: string | null + last_run_response_head: string | null } interface RepoDisplayRow { @@ -574,19 +594,46 @@ function resolveRepoDisplay(row: RepoDisplayRow): { repoName: string; repoPath: return { repoName: displayName, repoPath: row.repo_path ?? '' } } +function buildLastRunSummary(row: ScheduleJobWithRepoRow): ScheduleRunSummary | null { + if (row.last_run_id === null || row.last_run_id === undefined) { + return null + } + + return { + id: row.last_run_id, + status: row.last_run_status as ScheduleRunStatus, + startedAt: row.last_run_started_at ?? 0, + finishedAt: row.last_run_finished_at, + viewedAt: row.last_run_viewed_at, + preview: extractReportPreview(row.last_run_status === 'failed' ? row.last_run_error_text : row.last_run_response_head), + } +} + function rowToScheduleJobWithRepo(row: ScheduleJobWithRepoRow): ScheduleJobWithRepo { return { ...rowToScheduleJob(row), ...resolveRepoDisplay(row), repoUrl: row.repo_url ?? '', + lastRun: buildLastRunSummary(row), } } export function listAllScheduleJobsWithRepos(db: Database): ScheduleJobWithRepo[] { const stmt = db.prepare(` - SELECT sj.*, r.repo_url, r.local_path as repo_path, r.name as repo_name, r.source_path as repo_source_path + SELECT + sj.*, r.repo_url, r.local_path as repo_path, r.name as repo_name, r.source_path as repo_source_path, + sr.id AS last_run_id, + sr.status AS last_run_status, + sr.started_at AS last_run_started_at, + sr.finished_at AS last_run_finished_at, + sr.viewed_at AS last_run_viewed_at, + substr(sr.error_text, 1, 600) AS last_run_error_text, + substr(sr.response_text, 1, 600) AS last_run_response_head FROM schedule_jobs sj LEFT JOIN repos r ON sj.repo_id = r.id + LEFT JOIN schedule_runs sr ON sr.id = ( + SELECT id FROM schedule_runs WHERE job_id = sj.id ORDER BY started_at DESC LIMIT 1 + ) ORDER BY COALESCE(r.local_path, ''), sj.name `) const rows = stmt.all() as ScheduleJobWithRepoRow[] @@ -623,10 +670,15 @@ export interface ListAllRunsOptions { jobId?: number triggerSource?: string runId?: number + search?: string +} + +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (match) => `\\${match}`) } export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = {}): ScheduleRunWithContext[] { - const { limit = 50, offset = 0, status, repoId, jobId, triggerSource, runId } = options + const { limit = 50, offset = 0, status, repoId, jobId, triggerSource, runId, search } = options const conditions: string[] = [] const params: (string | number)[] = [] @@ -650,6 +702,13 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = conditions.push('sr.id = ?') params.push(runId) } + const searchTerm = search?.trim() + if (searchTerm) { + const pattern = `%${escapeLikePattern(searchTerm)}%` + const searchColumns = ['sj.name', 'sr.session_title', 'sr.error_text', 'sr.run_branch', 'r.name', 'r.local_path'] + conditions.push(`(${searchColumns.map((column) => `${column} LIKE ? ESCAPE '\\'`).join(' OR ')})`) + params.push(...searchColumns.map(() => pattern)) + } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' @@ -659,6 +718,7 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = sr.started_at, sr.finished_at, sr.created_at, sr.session_id, sr.session_title, NULL AS log_text, NULL AS response_text, sr.error_text, + sr.viewed_at, sr.run_branch, sr.commit_hash, sr.worktree_path, sj.name AS job_name, r.local_path AS repo_path, r.name AS repo_name, r.repo_url AS repo_url, r.source_path AS repo_source_path @@ -674,3 +734,99 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = const rows = stmt.all(...params) as ScheduleRunWithContextRow[] return rows.map(rowToScheduleRunWithContext) } + +const REPORT_PREVIEW_MAX_LENGTH = 160 + +function normalizeReportLine(rawLine: string): string { + return rawLine + .replace(/^\s*#{1,6}\s*/, '') + .replace(/^\s*>\s?/, '') + .replace(/^\s*(?:[-*]|\d+\.)\s+/, '') + .replace(/\*\*|__|`/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * Reduces a finished run's markdown report to a single short, plain-text line + * suitable for a notification or list preview, preferring the first heading + * over any conversational preamble. + */ +export function extractReportPreview(text: string | null): string | null { + if (!text) { + return null + } + + const rawLines = text.split('\n') + const headingLine = rawLines.find((rawLine) => /^\s*#{1,6}\s+\S/.test(rawLine)) + const line = headingLine + ? normalizeReportLine(headingLine) + : rawLines.map(normalizeReportLine).find((candidate) => candidate.length > 0) + + if (!line) { + return null + } + + return line.length > REPORT_PREVIEW_MAX_LENGTH + ? `${line.slice(0, REPORT_PREVIEW_MAX_LENGTH).trimEnd()}…` + : line +} + +export interface ScheduleRunWithUnreadPreview extends ScheduleRunWithContext { + preview: string | null +} + +interface UnreadScheduleRunRow extends ScheduleRunWithContextRow { + response_head: string | null +} + +export function listUnreadScheduleRuns(db: Database, limit: number = 20): ScheduleRunWithUnreadPreview[] { + const stmt = db.prepare(` + SELECT + sr.id, sr.job_id, sr.repo_id, sr.trigger_source, sr.status, + sr.started_at, sr.finished_at, sr.created_at, + sr.session_id, sr.session_title, sr.viewed_at, + NULL AS log_text, NULL AS response_text, sr.error_text, + sr.run_branch, sr.commit_hash, sr.worktree_path, + sj.name AS job_name, r.local_path AS repo_path, r.name AS repo_name, + r.repo_url AS repo_url, r.source_path AS repo_source_path, + substr(sr.response_text, 1, 600) AS response_head + FROM schedule_runs sr + JOIN schedule_jobs sj ON sr.job_id = sj.id + LEFT JOIN repos r ON sr.repo_id = r.id + WHERE sr.status IN ('completed', 'failed') AND sr.viewed_at IS NULL + ORDER BY (sr.status = 'failed') DESC, sr.finished_at DESC + LIMIT ? + `) + const rows = stmt.all(limit) as UnreadScheduleRunRow[] + return rows.map((row) => ({ + ...rowToScheduleRunWithContext(row), + preview: extractReportPreview(row.status === 'failed' ? row.error_text : row.response_head), + })) +} + +export function countUnreadScheduleRuns(db: Database): { total: number; failed: number } { + const stmt = db.prepare(` + SELECT + COUNT(*) AS total, + COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed + FROM schedule_runs + WHERE status IN ('completed', 'failed') AND viewed_at IS NULL + `) + const row = stmt.get() as { total: number; failed: number } + return { total: Number(row.total), failed: Number(row.failed) } +} + +export function markScheduleRunViewed(db: Database, runId: number): boolean { + const result = db + .prepare("UPDATE schedule_runs SET viewed_at = ? WHERE id = ? AND viewed_at IS NULL AND status IN ('completed', 'failed')") + .run(Date.now(), runId) + return result.changes > 0 +} + +export function markAllScheduleRunsViewed(db: Database): number { + const result = db + .prepare("UPDATE schedule_runs SET viewed_at = ? WHERE viewed_at IS NULL AND status IN ('completed', 'failed')") + .run(Date.now()) + return result.changes +} diff --git a/backend/src/routes/schedules.ts b/backend/src/routes/schedules.ts index 4c10f8f38..98b9c5d8d 100644 --- a/backend/src/routes/schedules.ts +++ b/backend/src/routes/schedules.ts @@ -66,13 +66,44 @@ export function createScheduleRoutes(scheduleService: ScheduleService) { })() : undefined const triggerSource = c.req.query('triggerSource') || undefined const runId = parseRunIdFilter(c.req.query('runId')) - const runs = scheduleService.listAllRuns({ limit, offset, status, repoId, jobId, triggerSource, runId }) + const search = c.req.query('search')?.slice(0, 200) || undefined + const runs = scheduleService.listAllRuns({ limit, offset, status, repoId, jobId, triggerSource, runId, search }) return c.json({ runs }) } catch (error) { return handleServiceError(c, error, 'Failed to list all schedule runs', ScheduleServiceError) } }) + app.get('/all/runs/unread', (c) => { + try { + const limit = parseRunListLimit(c.req.query('limit')) + const runs = scheduleService.listUnreadRuns(limit) + const counts = scheduleService.countUnreadRuns() + return c.json({ runs, total: counts.total, failed: counts.failed }) + } catch (error) { + return handleServiceError(c, error, 'Failed to list unread schedule runs', ScheduleServiceError) + } + }) + + app.post('/all/runs/viewed', (c) => { + try { + const updated = scheduleService.markAllRunsViewed() + return c.json({ updated }) + } catch (error) { + return handleServiceError(c, error, 'Failed to mark schedule runs viewed', ScheduleServiceError) + } + }) + + app.post('/all/runs/:runId/viewed', (c) => { + try { + const runId = parseId(c.req.param('runId'), 'run id', ScheduleServiceError) + const updated = scheduleService.markRunViewed(runId) + return c.json({ updated }) + } catch (error) { + return handleServiceError(c, error, 'Failed to mark schedule run viewed', ScheduleServiceError) + } + }) + app.get('/', (c) => { try { const repoId = parseId(c.req.param('id'), 'repo id', ScheduleServiceError) diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 85e51e595..e97294ff3 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -24,6 +24,10 @@ import { listAllScheduleJobsWithRepos, listScheduleRunArtifactsByJob, listAllScheduleRuns, + listUnreadScheduleRuns, + countUnreadScheduleRuns, + markScheduleRunViewed, + markAllScheduleRunsViewed, listEnabledScheduleJobs, listScheduleJobIdsByRepo, listScheduleJobsByRepo, @@ -35,7 +39,7 @@ import { updateScheduleRunMetadata, updateScheduleRunWorktree, } from '../db/schedules' -import type { ListAllRunsOptions, ScheduleRunWithContext } from '../db/schedules' +import type { ListAllRunsOptions, ScheduleRunWithContext, ScheduleRunWithUnreadPreview } from '../db/schedules' import { buildCreateSchedulePersistenceInput, buildUpdatedSchedulePersistenceInput, @@ -373,6 +377,22 @@ export class ScheduleService { return listAllScheduleRuns(this.db, { ...options, limit, offset }) } + listUnreadRuns(limit: number): ScheduleRunWithUnreadPreview[] { + return listUnreadScheduleRuns(this.db, limit) + } + + countUnreadRuns(): { total: number; failed: number } { + return countUnreadScheduleRuns(this.db) + } + + markRunViewed(runId: number): boolean { + return markScheduleRunViewed(this.db, runId) + } + + markAllRunsViewed(): number { + return markAllScheduleRunsViewed(this.db) + } + async recoverRunningRuns(): Promise { const runningRuns = listRunningScheduleRuns(this.db) diff --git a/backend/test/db/schedule-migrations.test.ts b/backend/test/db/schedule-migrations.test.ts index c136df3f6..1493d320f 100644 --- a/backend/test/db/schedule-migrations.test.ts +++ b/backend/test/db/schedule-migrations.test.ts @@ -5,6 +5,7 @@ import migration008 from '../../src/db/migrations/008-schedule-cron-support' import migration015 from '../../src/db/migrations/015-schedule-worktree-isolation' import migration021 from '../../src/db/migrations/021-drop-schedule-run-workspace-id' import migration022 from '../../src/db/migrations/022-schedule-runs-session-index' +import migration024 from '../../src/db/migrations/024-schedule-runs-viewed-at' describe('schedule migrations', () => { it('creates schedule jobs with nullable interval minutes in v7', () => { @@ -237,3 +238,53 @@ describe('migration 022 - schedule run session index', () => { db.close() }) }) + +describe('migration 024 - schedule runs viewed at', () => { + it('adds viewed_at, backfills finished runs, and creates the unread index', () => { + const db = new Database(':memory:') + db.run('CREATE TABLE schedule_runs (id INTEGER PRIMARY KEY, status TEXT NOT NULL, started_at INTEGER NOT NULL, finished_at INTEGER)') + db.run("INSERT INTO schedule_runs (id, status, started_at, finished_at) VALUES (1, 'completed', 100, 200)") + db.run("INSERT INTO schedule_runs (id, status, started_at, finished_at) VALUES (2, 'failed', 300, 400)") + db.run("INSERT INTO schedule_runs (id, status, started_at, finished_at) VALUES (3, 'running', 500, NULL)") + db.run("INSERT INTO schedule_runs (id, status, started_at, finished_at) VALUES (4, 'completed', 600, NULL)") + + migration024.up(db) + + const columns = (db.prepare('PRAGMA table_info(schedule_runs)').all() as { name: string }[]).map((column) => column.name) + expect(columns).toContain('viewed_at') + + const rows = db.prepare('SELECT id, viewed_at FROM schedule_runs ORDER BY id').all() as { id: number; viewed_at: number | null }[] + expect(rows).toEqual([ + { id: 1, viewed_at: 200 }, + { id: 2, viewed_at: 400 }, + { id: 3, viewed_at: null }, + { id: 4, viewed_at: 600 }, + ]) + + const indexes = (db.prepare('PRAGMA index_list(schedule_runs)').all() as { name: string }[]).map((index) => index.name) + expect(indexes).toContain('idx_schedule_runs_unread') + + db.close() + }) + + it('is idempotent and drops the index and column on rollback', () => { + const db = new Database(':memory:') + db.run('CREATE TABLE schedule_runs (id INTEGER PRIMARY KEY, status TEXT NOT NULL, started_at INTEGER NOT NULL, finished_at INTEGER)') + db.run("INSERT INTO schedule_runs (id, status, started_at, finished_at) VALUES (1, 'completed', 100, 200)") + + migration024.up(db) + migration024.up(db) + + const columns = (db.prepare('PRAGMA table_info(schedule_runs)').all() as { name: string }[]).map((column) => column.name) + expect(columns).toContain('viewed_at') + + migration024.down(db) + + const droppedColumns = (db.prepare('PRAGMA table_info(schedule_runs)').all() as { name: string }[]).map((column) => column.name) + expect(droppedColumns).not.toContain('viewed_at') + const indexes = (db.prepare('PRAGMA index_list(schedule_runs)').all() as { name: string }[]).map((index) => index.name) + expect(indexes).not.toContain('idx_schedule_runs_unread') + + db.close() + }) +}) diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index da0198477..95da50b35 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -39,6 +39,7 @@ function makeRunRow(overrides: Record = {}) { status: 'running', started_at: Date.UTC(2026, 2, 9, 12, 0, 0), finished_at: null, + viewed_at: null, created_at: Date.UTC(2026, 2, 9, 12, 0, 0), session_id: 'ses-1', session_title: 'Scheduled: Weekly engineering summary', @@ -603,6 +604,28 @@ describe('schedule database queries', () => { expect(stmt.all).toHaveBeenCalledWith(42, 99, 1, 0) }) + it('applies an escaped search term across run, job, and repo columns', () => { + const stmt = { all: vi.fn().mockReturnValue([]) } + mockDb.prepare.mockReturnValue(stmt) + + schedulesDb.listAllScheduleRuns(mockDb, { search: ' 50%_off ' }) + + const sql = mockDb.prepare.mock.calls[0][0] as string + expect(sql).toContain("sj.name LIKE ? ESCAPE '\\'") + expect(sql).toContain("r.local_path LIKE ? ESCAPE '\\'") + const pattern = '%50\\%\\_off%' + expect(stmt.all).toHaveBeenCalledWith(pattern, pattern, pattern, pattern, pattern, pattern, 50, 0) + }) + + it('ignores a blank search term', () => { + const stmt = { all: vi.fn().mockReturnValue([]) } + mockDb.prepare.mockReturnValue(stmt) + + schedulesDb.listAllScheduleRuns(mockDb, { search: ' ' }) + + expect(stmt.all).toHaveBeenCalledWith(50, 0) + }) + it('applies limit and offset', () => { const stmt = { all: vi.fn().mockReturnValue([]) } mockDb.prepare.mockReturnValue(stmt) @@ -690,4 +713,160 @@ describe('schedule database queries', () => { expect(cleared).toBe(0) expect(mockDb.prepare).not.toHaveBeenCalled() }) + + it('maps lastRun onto jobs with repo context from the latest run', () => { + const row = { + ...makeJobRow(), + repo_url: 'https://github.com/test/my-repo', + repo_path: '/home/user/my-repo', + repo_name: 'my-repo', + repo_source_path: null, + last_run_id: 5, + last_run_status: 'failed', + last_run_started_at: Date.UTC(2026, 2, 9, 12, 0, 0), + last_run_finished_at: Date.UTC(2026, 2, 9, 12, 5, 0), + last_run_viewed_at: null, + last_run_error_text: 'Model unavailable', + last_run_response_head: null, + } + const stmt = { all: vi.fn().mockReturnValue([row]) } + mockDb.prepare.mockReturnValue(stmt) + + const jobs = schedulesDb.listAllScheduleJobsWithRepos(mockDb) + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('substr(sr.response_text, 1, 600) AS last_run_response_head')) + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('ORDER BY started_at DESC LIMIT 1')) + expect(jobs[0]?.lastRun).toEqual({ + id: 5, + status: 'failed', + startedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + finishedAt: Date.UTC(2026, 2, 9, 12, 5, 0), + viewedAt: null, + preview: 'Model unavailable', + }) + }) + + it('maps lastRun to null when a job has no runs', () => { + const row = { + ...makeJobRow(), + repo_url: null, + repo_path: '/home/user/my-repo', + repo_name: 'my-repo', + repo_source_path: null, + last_run_id: null, + last_run_status: null, + last_run_started_at: null, + last_run_finished_at: null, + last_run_viewed_at: null, + last_run_error_text: null, + last_run_response_head: null, + } + const stmt = { all: vi.fn().mockReturnValue([row]) } + mockDb.prepare.mockReturnValue(stmt) + + const jobs = schedulesDb.listAllScheduleJobsWithRepos(mockDb) + + expect(jobs[0]?.lastRun).toBeNull() + }) + + it('lists unread runs only for finished unviewed completed/failed runs with a preview', () => { + const completedRow = { + ...makeRunRow({ id: 5, status: 'completed', finished_at: Date.UTC(2026, 2, 9, 12, 5, 0), response_text: null, error_text: null }), + job_name: 'Weekly summary', + repo_path: '/home/user/my-repo', + response_head: '## Summary\nEverything is stable.', + } + const failedRow = { + ...makeRunRow({ id: 6, status: 'failed', finished_at: Date.UTC(2026, 2, 9, 12, 6, 0), response_text: null, error_text: 'Deploy failed\nsee logs' }), + job_name: 'Weekly summary', + repo_path: '/home/user/my-repo', + response_head: null, + } + const stmt = { all: vi.fn().mockReturnValue([failedRow, completedRow]) } + mockDb.prepare.mockReturnValue(stmt) + + const runs = schedulesDb.listUnreadScheduleRuns(mockDb, 20) + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining("sr.status IN ('completed', 'failed') AND sr.viewed_at IS NULL")) + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('substr(sr.response_text, 1, 600) AS response_head')) + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining("ORDER BY (sr.status = 'failed') DESC, sr.finished_at DESC")) + expect(stmt.all).toHaveBeenCalledWith(20) + expect(runs).toHaveLength(2) + expect(runs[0]).toMatchObject({ id: 6, status: 'failed', preview: 'Deploy failed' }) + expect(runs[1]).toMatchObject({ id: 5, status: 'completed', preview: 'Summary' }) + }) + + it('counts unread runs in a single aggregate query', () => { + const stmt = { get: vi.fn().mockReturnValue({ total: 3, failed: 1 }) } + mockDb.prepare.mockReturnValue(stmt) + + const counts = schedulesDb.countUnreadScheduleRuns(mockDb) + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining("WHERE status IN ('completed', 'failed') AND viewed_at IS NULL")) + expect(counts).toEqual({ total: 3, failed: 1 }) + }) + + it('marks a single finished run viewed with an atomic guarded update', () => { + const stmt = { run: vi.fn().mockReturnValue({ changes: 1 }) } + mockDb.prepare.mockReturnValue(stmt) + + const updated = schedulesDb.markScheduleRunViewed(mockDb, 5) + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining("WHERE id = ? AND viewed_at IS NULL AND status IN ('completed', 'failed')")) + expect(stmt.run).toHaveBeenCalledWith(expect.any(Number), 5) + expect(updated).toBe(true) + }) + + it('reports no change when marking an already viewed or running run', () => { + const stmt = { run: vi.fn().mockReturnValue({ changes: 0 }) } + mockDb.prepare.mockReturnValue(stmt) + + const updated = schedulesDb.markScheduleRunViewed(mockDb, 5) + + expect(updated).toBe(false) + }) + + it('marks every unviewed finished run viewed and returns the changed count', () => { + const stmt = { run: vi.fn().mockReturnValue({ changes: 4 }) } + mockDb.prepare.mockReturnValue(stmt) + + const updated = schedulesDb.markAllScheduleRunsViewed(mockDb) + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining("WHERE viewed_at IS NULL AND status IN ('completed', 'failed')")) + expect(stmt.run).toHaveBeenCalledWith(expect.any(Number)) + expect(updated).toBe(4) + }) + + describe('extractReportPreview', () => { + it('returns null for empty input', () => { + expect(schedulesDb.extractReportPreview(null)).toBeNull() + expect(schedulesDb.extractReportPreview('')).toBeNull() + expect(schedulesDb.extractReportPreview(' \n \n')).toBeNull() + }) + + it('strips markdown markers and returns the first non-empty line', () => { + expect(schedulesDb.extractReportPreview('## Heading\n\nBody text')).toBe('Heading') + expect(schedulesDb.extractReportPreview('- item one\n- item two')).toBe('item one') + expect(schedulesDb.extractReportPreview('1. first step')).toBe('first step') + expect(schedulesDb.extractReportPreview('> quoted line')).toBe('quoted line') + expect(schedulesDb.extractReportPreview('**bold** and `code`')).toBe('bold and code') + }) + + it('skips leading blank lines before the first content', () => { + expect(schedulesDb.extractReportPreview('\n\n \n### Real heading')).toBe('Real heading') + expect(schedulesDb.extractReportPreview('I have enough evidence.\n\n# Drift Review\n\nBody')).toBe('Drift Review') + }) + + it('collapses whitespace', () => { + expect(schedulesDb.extractReportPreview(' hello world ')).toBe('hello world') + }) + + it('caps long previews with a trailing ellipsis', () => { + const preview = schedulesDb.extractReportPreview('x'.repeat(300)) + + expect(preview).not.toBeNull() + expect(preview?.endsWith('…')).toBe(true) + expect(preview?.replace(/…$/, '')).toHaveLength(160) + }) + }) }) diff --git a/backend/test/routes/schedules.test.ts b/backend/test/routes/schedules.test.ts index 6cfc779e4..24b949237 100644 --- a/backend/test/routes/schedules.test.ts +++ b/backend/test/routes/schedules.test.ts @@ -15,6 +15,10 @@ const scheduleService = { listAllEnabledJobs: vi.fn(), listAllJobsWithRepos: vi.fn(), listAllRuns: vi.fn(), + listUnreadRuns: vi.fn(), + countUnreadRuns: vi.fn(), + markRunViewed: vi.fn(), + markAllRunsViewed: vi.fn(), recoverRunningRuns: vi.fn(), setJobChangeHandler: vi.fn(), } @@ -302,4 +306,61 @@ describe('Schedule Routes', () => { expect(body.error).toBe('Run id must be a positive integer') expect(scheduleService.listAllRuns).not.toHaveBeenCalled() }) + + it('lists unread runs with counts using the default limit', async () => { + scheduleService.listUnreadRuns.mockReturnValue([ + { id: 5, status: 'failed', preview: 'Deploy failed' }, + ]) + scheduleService.countUnreadRuns.mockReturnValue({ total: 3, failed: 1 }) + + const response = await app.request('/repos/42/schedules/all/runs/unread') + const body = await response.json() as { runs: Array<{ id: number }>; total: number; failed: number } + + expect(response.status).toBe(200) + expect(body.runs).toHaveLength(1) + expect(body.total).toBe(3) + expect(body.failed).toBe(1) + expect(scheduleService.listUnreadRuns).toHaveBeenCalledWith(20) + }) + + it('passes a bounded limit to the unread runs query', async () => { + scheduleService.listUnreadRuns.mockReturnValue([]) + scheduleService.countUnreadRuns.mockReturnValue({ total: 0, failed: 0 }) + + const response = await app.request('/repos/42/schedules/all/runs/unread?limit=500') + + expect(response.status).toBe(200) + expect(scheduleService.listUnreadRuns).toHaveBeenCalledWith(100) + }) + + it('marks all finished runs viewed', async () => { + scheduleService.markAllRunsViewed.mockReturnValue(4) + + const response = await app.request('/repos/42/schedules/all/runs/viewed', { method: 'POST' }) + const body = await response.json() as { updated: number } + + expect(response.status).toBe(200) + expect(body.updated).toBe(4) + expect(scheduleService.markAllRunsViewed).toHaveBeenCalled() + }) + + it('marks a single run viewed', async () => { + scheduleService.markRunViewed.mockReturnValue(true) + + const response = await app.request('/repos/42/schedules/all/runs/5/viewed', { method: 'POST' }) + const body = await response.json() as { updated: boolean } + + expect(response.status).toBe(200) + expect(body.updated).toBe(true) + expect(scheduleService.markRunViewed).toHaveBeenCalledWith(5) + }) + + it('rejects an invalid run id when marking a run viewed', async () => { + const response = await app.request('/repos/42/schedules/all/runs/not-a-number/viewed', { method: 'POST' }) + const body = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(body.error).toBe('Invalid run id') + expect(scheduleService.markRunViewed).not.toHaveBeenCalled() + }) }) diff --git a/backend/test/services/schedules.permission.test.ts b/backend/test/services/schedules.permission.test.ts index a41b0883e..b3c344c4d 100644 --- a/backend/test/services/schedules.permission.test.ts +++ b/backend/test/services/schedules.permission.test.ts @@ -137,6 +137,7 @@ const baseRun: ScheduleRun = { status: 'running', startedAt: Date.UTC(2026, 2, 9, 12, 5, 0), finishedAt: null, + viewedAt: null, createdAt: Date.UTC(2026, 2, 9, 12, 5, 0), sessionId: null, sessionTitle: null, diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index ea1d20efb..98abb0c0d 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -160,6 +160,7 @@ const baseRun: ScheduleRun = { status: 'running', startedAt: Date.UTC(2026, 2, 9, 12, 5, 0), finishedAt: null, + viewedAt: null, createdAt: Date.UTC(2026, 2, 9, 12, 5, 0), sessionId: null, sessionTitle: null, diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index 6af986d7b..71157b443 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -4,13 +4,24 @@ import type { CreateScheduleJobRequest, ScheduleJob, ScheduleRun, + ScheduleRunStatus, UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' +export interface ScheduleRunSummary { + id: number + status: ScheduleRunStatus + startedAt: number + finishedAt: number | null + viewedAt: number | null + preview: string | null +} + export interface ScheduleJobWithRepo extends ScheduleJob { repoName: string repoPath: string repoUrl: string + lastRun: ScheduleRunSummary | null } export interface ScheduleRunWithContext extends ScheduleRun { @@ -19,6 +30,16 @@ export interface ScheduleRunWithContext extends ScheduleRun { repoPath: string } +export interface UnreadScheduleRun extends ScheduleRunWithContext { + preview: string | null +} + +export interface UnreadScheduleRunsResponse { + runs: UnreadScheduleRun[] + total: number + failed: number +} + export interface ListAllRunsParams { limit?: number offset?: number @@ -27,6 +48,7 @@ export interface ListAllRunsParams { jobId?: number triggerSource?: string runId?: number + search?: string } export interface ScheduleCount { @@ -47,12 +69,25 @@ export async function listAllScheduleRuns(params: ListAllRunsParams = {}): Promi if (params.jobId !== undefined) searchParams.set('jobId', String(params.jobId)) if (params.triggerSource) searchParams.set('triggerSource', params.triggerSource) if (params.runId !== undefined) searchParams.set('runId', String(params.runId)) + if (params.search) searchParams.set('search', params.search) const qs = searchParams.toString() return fetchWrapper(`${API_BASE_URL}/api/schedules/all/runs${qs ? `?${qs}` : ''}`) } -export async function listRepoSchedules(repoId: number): Promise<{ jobs: ScheduleJob[] }> { - return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules`) +export async function listUnreadScheduleRuns(limit: number = 20): Promise { + return fetchWrapper(`${API_BASE_URL}/api/schedules/all/runs/unread?limit=${limit}`) +} + +export async function markScheduleRunViewed(runId: number): Promise<{ updated: boolean }> { + return fetchWrapper(`${API_BASE_URL}/api/schedules/all/runs/${runId}/viewed`, { + method: 'POST', + }) +} + +export async function markAllScheduleRunsViewed(): Promise<{ updated: number }> { + return fetchWrapper(`${API_BASE_URL}/api/schedules/all/runs/viewed`, { + method: 'POST', + }) } export async function getScheduleCounts(): Promise> { diff --git a/frontend/src/components/navigation/DesktopSidebar.test.tsx b/frontend/src/components/navigation/DesktopSidebar.test.tsx index a7d1ace15..a9aa694ee 100644 --- a/frontend/src/components/navigation/DesktopSidebar.test.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.test.tsx @@ -149,7 +149,11 @@ describe('DesktopSidebar', () => { expect(screen.getByText('Assistant')).toBeInTheDocument() }) - it('renders primary CTA for schedules routes', () => { + it.each([ + ['/schedules', 'New Schedule'], + ['/repos/5/schedules', 'New Schedule'], + ['/assistant', 'New Session'], + ])('does not duplicate the page header create action on %s', (path, headerAction) => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ @@ -158,34 +162,12 @@ describe('DesktopSidebar', () => { logout: vi.fn(), } as any) - render(, { wrapper: createWrapper(['/schedules']) }) + render(, { wrapper: createWrapper([path]) }) - expect(screen.getByText('New Schedule')).toBeInTheDocument() + expect(screen.queryByText(headerAction)).toBeNull() expect(screen.getByText('Assistant')).toBeInTheDocument() }) - it('dispatches oc:sidebar:action event when primary CTA is clicked', () => { - const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent') - vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) - vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: vi.fn(), - } as any) - - render(, { wrapper: createWrapper(['/schedules']) }) - - fireEvent.click(screen.getByText('New Schedule')) - - expect(dispatchEventSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'oc:sidebar:action', - detail: { action: 'new-schedule' }, - }) - ) - }) - it('opens dialog items by updating the dialog query param (push) and closes on back', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index 1a3bd34b9..9e61431cc 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { useDesktop } from '@/hooks/useDesktop' import { useSidebarCollapsed, useSidebarSections } from '@/hooks/useSidebarCollapsed' -import { emitSidebarAction } from '@/hooks/useSidebarAction' import { useAuth } from '@/hooks/useAuth' import { useUrlParams } from '@/hooks/useUrlParams' import { buildNavModel, type MoreDrawerItem, type NavPrimaryCta } from '@/components/navigation/moreDrawerItems' @@ -39,15 +38,6 @@ export function DesktopSidebar() { } const { primary, items } = buildNavModel(location.pathname) - const desktopPrimary = primary.filter((item) => item.key !== 'new-repo') - - const handlePrimaryClick = (item: NavPrimaryCta) => { - if (item.to) { - navigate(item.to) - } else if (item.onSelect) { - emitSidebarAction(item.onSelect) - } - } const handleItemClick = (item: MoreDrawerItem) => { if (item.to) { @@ -85,15 +75,15 @@ export function DesktopSidebar() { return ( <> - {desktopPrimary.length > 0 && ( + {primary.length > 0 && ( - {desktopPrimary.map((item: NavPrimaryCta) => ( + {primary.map((item: NavPrimaryCta) => ( handlePrimaryClick(item)} + onClick={() => navigate(item.to)} asPrimary variant={item.variant} /> diff --git a/frontend/src/components/navigation/MobileTabBar.test.tsx b/frontend/src/components/navigation/MobileTabBar.test.tsx index 21adcc8bf..6cf4c99fc 100644 --- a/frontend/src/components/navigation/MobileTabBar.test.tsx +++ b/frontend/src/components/navigation/MobileTabBar.test.tsx @@ -4,6 +4,10 @@ vi.mock('@/hooks/useMobile', () => ({ useMobile: vi.fn(), })) +vi.mock('@/hooks/useSchedules', () => ({ + useUnreadScheduleRuns: vi.fn(() => ({ data: undefined })), +})) + import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, beforeEach } from 'vitest' diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index ab3870a33..da79d3e39 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -6,6 +6,7 @@ import { useMobile } from '@/hooks/useMobile' import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { useUrlParams } from '@/hooks/useUrlParams' import { useScheduleUrlState, type ScheduleTab } from '@/hooks/useScheduleUrlState' +import { useUnreadScheduleRuns } from '@/hooks/useSchedules' import { getAssistantPath, isAssistantPath, parseRepoRoute } from '@/lib/navigation' import { openDialogParam } from '@/hooks/useDialogParam' @@ -27,6 +28,7 @@ interface GlobalTabsArgs { isInsideRepo: boolean repoId: number | null updateParams: ReturnType['updateParams'] + unreadScheduleCount: number } type TabBarMode = 'hidden' | 'global' | 'schedule' @@ -62,7 +64,7 @@ function getMobileTabRouteState(pathname: string): MobileTabRouteState { } } -function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideRepo, repoId, updateParams }: GlobalTabsArgs): TabDef[] { +function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideRepo, repoId, updateParams, unreadScheduleCount }: GlobalTabsArgs): TabDef[] { const openRepoDialog = (dialog: string) => { openDialogParam(updateParams, dialog) } @@ -120,6 +122,7 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR icon: CalendarClock, onClick: () => navigate('/schedules'), active: pathname === '/schedules' && !openSheet, + badge: unreadScheduleCount > 0, }] : []), { @@ -199,8 +202,10 @@ export const MobileTabBar = memo(function MobileTabBar() { const { openSheet, open, close } = useMobileTabBar() const { updateParams } = useUrlParams() const { scheduleTab, setScheduleTab } = useScheduleUrlState() + const { data: unreadRuns } = useUnreadScheduleRuns() const isMobile = useMobile() const routeState = useMemo(() => getMobileTabRouteState(pathname), [pathname]) + const unreadScheduleCount = unreadRuns?.total ?? 0 const tabs = useMemo( () => (routeState.mode === 'schedule' @@ -214,6 +219,7 @@ export const MobileTabBar = memo(function MobileTabBar() { isInsideRepo: routeState.isInsideRepo, repoId: routeState.repoId, updateParams, + unreadScheduleCount, })), [ routeState, @@ -225,6 +231,7 @@ export const MobileTabBar = memo(function MobileTabBar() { close, navigate, updateParams, + unreadScheduleCount, ], ) diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index 104ab468f..353fed70e 100644 --- a/frontend/src/components/navigation/moreDrawerItems.test.ts +++ b/frontend/src/components/navigation/moreDrawerItems.test.ts @@ -109,75 +109,23 @@ describe('buildMoreItems', () => { }) describe('buildNavModel', () => { - it('returns new-repo primary CTA for root path', () => { - const model = buildNavModel('/') - expect(model.primary).toHaveLength(2) - expect(model.primary[0].key).toBe('new-repo') - expect(model.primary[0].onSelect).toBe('new-repo') - expect(model.primary[1].key).toBe('assistant') - expect(model.primary[1].to).toBe('/assistant') - }) - - it('returns only the assistant primary CTA for repo detail', () => { - const model = buildNavModel('/repos/5') - expect(model.primary).toHaveLength(1) - expect(model.primary[0].key).toBe('assistant') - expect(model.primary[0].to).toBe('/assistant') - }) - - it('returns only the assistant primary CTA for session detail', () => { - const model = buildNavModel('/repos/5/sessions/abc') + it.each([ + '/', + '/repos/5', + '/repos/5/sessions/abc', + '/repos/5/assistant', + '/assistant', + '/schedules', + '/repos/5/schedules', + '/unknown/path', + ])('returns only the assistant primary CTA for %s', (path) => { + const model = buildNavModel(path) expect(model.primary).toHaveLength(1) expect(model.primary[0].key).toBe('assistant') expect(model.primary[0].to).toBe('/assistant') expect(model.primary[0].variant).toBe('secondary') }) - it('returns new-session and assistant primary CTAs for assistant workspace', () => { - const model = buildNavModel('/repos/5/assistant') - expect(model.primary).toHaveLength(2) - expect(model.primary[0].key).toBe('new-session') - expect(model.primary[0].onSelect).toBe('new-session') - expect(model.primary[0].variant).toBe('primary') - expect(model.primary[1].key).toBe('assistant') - expect(model.primary[1].to).toBe('/assistant') - expect(model.primary[1].variant).toBe('secondary') - }) - - it('returns new-session and assistant primary CTAs for canonical /assistant', () => { - const model = buildNavModel('/assistant') - expect(model.primary).toHaveLength(2) - expect(model.primary[0].key).toBe('new-session') - expect(model.primary[0].onSelect).toBe('new-session') - expect(model.primary[0].variant).toBe('primary') - expect(model.primary[1].key).toBe('assistant') - expect(model.primary[1].to).toBe('/assistant') - expect(model.primary[1].variant).toBe('secondary') - }) - - it('returns new-schedule primary CTA for schedules routes', () => { - const model1 = buildNavModel('/schedules') - expect(model1.primary).toHaveLength(2) - expect(model1.primary[0].key).toBe('new-schedule') - expect(model1.primary[0].onSelect).toBe('new-schedule') - expect(model1.primary[1].key).toBe('assistant') - expect(model1.primary[1].to).toBe('/assistant') - - const model2 = buildNavModel('/repos/5/schedules') - expect(model2.primary).toHaveLength(2) - expect(model2.primary[0].key).toBe('new-schedule') - expect(model2.primary[0].onSelect).toBe('new-schedule') - expect(model2.primary[1].key).toBe('assistant') - expect(model2.primary[1].to).toBe('/assistant') - }) - - it('returns assistant primary for unknown routes', () => { - const model = buildNavModel('/unknown/path') - expect(model.primary).toHaveLength(1) - expect(model.primary[0].key).toBe('assistant') - expect(model.primary[0].to).toBe('/assistant') - }) - it('preserves backwards compatibility with buildMoreItems', () => { const model = buildNavModel('/repos/42') const items = buildMoreItems('/repos/42') diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 7fa438c8e..7bc859118 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -1,7 +1,6 @@ import type { LucideIcon } from 'lucide-react' -import { Plug, Sparkles, ShieldOff, CalendarClock, GitCommitHorizontal, Settings, LogOut, Plus, Bot, Folder, Clock, SquarePlus, Home } from 'lucide-react' +import { Plug, Sparkles, ShieldOff, CalendarClock, GitCommitHorizontal, Settings, LogOut, Bot, Folder, Home } from 'lucide-react' import { getAssistantPath, isAssistantPath } from '@/lib/navigation' -import type { SidebarActionKey } from '@/hooks/useSidebarAction' export interface MoreDrawerItem { key: string @@ -16,8 +15,7 @@ export interface NavPrimaryCta { key: string label: string icon: LucideIcon - to?: string - onSelect?: SidebarActionKey + to: string variant?: 'primary' | 'secondary' } @@ -26,13 +24,13 @@ export interface NavModel { items: MoreDrawerItem[] } -function getAssistantNavItem(_pathname: string, variant: NavPrimaryCta['variant'] = 'secondary'): NavPrimaryCta { +function getAssistantNavItem(): NavPrimaryCta { return { key: 'assistant', label: 'Assistant', icon: Bot, to: getAssistantPath(), - variant, + variant: 'secondary', } } @@ -64,7 +62,7 @@ function buildRouteNavModel(pathname: string): NavModel { ] return { - primary: [getAssistantNavItem(pathname)], + primary: [getAssistantNavItem()], items, } } @@ -82,7 +80,7 @@ function buildRouteNavModel(pathname: string): NavModel { ] return { - primary: [getAssistantNavItem(pathname)], + primary: [getAssistantNavItem()], items, } } @@ -99,30 +97,14 @@ function buildRouteNavModel(pathname: string): NavModel { ] return { - primary: [ - { key: 'new-session', label: 'New Session', icon: SquarePlus, onSelect: 'new-session', variant: 'primary' }, - getAssistantNavItem(pathname, 'secondary'), - ], + primary: [getAssistantNavItem()], items, } } - if (pathname === '/schedules' || /^\/repos\/\d+\/schedules$/.test(pathname)) { - return { - primary: [ - { key: 'new-schedule', label: 'New Schedule', icon: Clock, onSelect: 'new-schedule', variant: 'primary' }, - getAssistantNavItem(pathname), - ], - items: baseItems, - } - } - if (pathname === '/') { return { - primary: [ - { key: 'new-repo', label: 'New Repo', icon: Plus, onSelect: 'new-repo', variant: 'primary' }, - getAssistantNavItem(pathname), - ], + primary: [getAssistantNavItem()], items: [ { key: 'all-schedules', label: 'All Schedules', icon: CalendarClock, to: '/schedules' }, { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, @@ -132,9 +114,7 @@ function buildRouteNavModel(pathname: string): NavModel { } return { - primary: [ - getAssistantNavItem(pathname), - ], + primary: [getAssistantNavItem()], items: baseItems, } } diff --git a/frontend/src/components/notifications/PendingActionsGroup.tsx b/frontend/src/components/notifications/PendingActionsGroup.tsx index 3c2ddb822..529f98c9f 100644 --- a/frontend/src/components/notifications/PendingActionsGroup.tsx +++ b/frontend/src/components/notifications/PendingActionsGroup.tsx @@ -1,5 +1,6 @@ import { Bell, HelpCircle } from 'lucide-react' import { PendingActionBadge } from '@/components/ui/pending-action-badge' +import { ScheduleReportsBell } from '@/components/notifications/ScheduleReportsBell' import { usePermissions, useForms } from '@/contexts/EventContext' export function PendingActionsGroup() { @@ -8,6 +9,7 @@ export function PendingActionsGroup() { return ( <> + ({ + useUnreadScheduleRuns: vi.fn(), + useMarkAllScheduleRunsViewed: vi.fn(), +})) + +vi.mock('@/hooks/useSchedules', () => ({ + useUnreadScheduleRuns: mocks.useUnreadScheduleRuns, + useMarkAllScheduleRunsViewed: mocks.useMarkAllScheduleRunsViewed, +})) + +vi.mock('@/hooks/useMobile', () => ({ + useMobile: vi.fn(() => false), +})) + +function renderBell() { + return render( + + + , + ) +} + +describe('ScheduleReportsBell', () => { + beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false + Element.prototype.setPointerCapture ??= () => {} + Element.prototype.releasePointerCapture ??= () => {} + Element.prototype.scrollIntoView ??= () => {} + }) + + beforeEach(() => { + vi.clearAllMocks() + mocks.useMarkAllScheduleRunsViewed.mockReturnValue({ mutate: vi.fn(), isPending: false }) + }) + + it('renders the unread count badge', () => { + mocks.useUnreadScheduleRuns.mockReturnValue({ data: { runs: [], total: 3, failed: 0 } }) + renderBell() + + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('uses destructive styling when a run failed', () => { + mocks.useUnreadScheduleRuns.mockReturnValue({ data: { runs: [], total: 3, failed: 1 } }) + renderBell() + + expect(screen.getByTestId('schedule-reports-count')).toHaveClass('bg-destructive') + }) + + it('shows the all-caught-up empty state when there are no unread runs', async () => { + const user = userEvent.setup() + mocks.useUnreadScheduleRuns.mockReturnValue({ data: { runs: [], total: 0, failed: 0 } }) + renderBell() + + await user.click(screen.getByRole('button', { name: 'Reports, 0 unread' })) + + expect(await screen.findByText('All caught up')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/notifications/ScheduleReportsBell.tsx b/frontend/src/components/notifications/ScheduleReportsBell.tsx new file mode 100644 index 000000000..ac864181e --- /dev/null +++ b/frontend/src/components/notifications/ScheduleReportsBell.tsx @@ -0,0 +1,182 @@ +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { CheckCircle2, Inbox, XCircle } from 'lucide-react' +import { formatDistanceToNow } from 'date-fns' +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { BottomSheet, BottomSheetContent, BottomSheetHeader } from '@/components/ui/bottom-sheet' +import { useMobile } from '@/hooks/useMobile' +import { useMarkAllScheduleRunsViewed, useUnreadScheduleRuns } from '@/hooks/useSchedules' +import { cn } from '@/lib/utils' +import type { UnreadScheduleRun } from '@/api/schedules' + +function ScheduleReportsCount({ total, failed }: { total: number; failed: number }) { + if (total <= 0) return null + + return ( + 0 ? 'bg-destructive' : 'bg-primary', + )} + > + {total > 9 ? '9+' : total} + + ) +} + +function ScheduleReportRow({ run, rowClassName, onClick }: { run: UnreadScheduleRun; rowClassName?: string; onClick: () => void }) { + const failed = run.status === 'failed' + const StatusIcon = failed ? XCircle : CheckCircle2 + + return ( + + ) +} + +interface ScheduleReportsListProps { + runs: UnreadScheduleRun[] + total: number + rowClassName?: string + onMarkAllRead: () => void + markAllPending: boolean + onNavigate?: () => void + showTitle?: boolean +} + +function ScheduleReportsList({ runs, total, rowClassName, onMarkAllRead, markAllPending, onNavigate, showTitle = true }: ScheduleReportsListProps) { + const navigate = useNavigate() + + const openRun = (runId: number) => { + onNavigate?.() + navigate(`/schedules?scheduleTab=runs&runId=${runId}`) + } + + return ( +
+
+
+ {showTitle && Reports} + {total} unread +
+ +
+ {runs.length === 0 ? ( +
+ +

All caught up

+

New scheduled reports will show up here.

+
+ ) : ( +
+ {runs.map((run) => ( + openRun(run.id)} /> + ))} +
+ )} + + All runs + +
+ ) +} + +export function ScheduleReportsBell() { + const isMobile = useMobile() + const { data } = useUnreadScheduleRuns() + const markAllViewed = useMarkAllScheduleRunsViewed() + const [open, setOpen] = useState(false) + + const runs = data?.runs ?? [] + const total = data?.total ?? 0 + const failed = data?.failed ?? 0 + + const markAllRead = () => { + markAllViewed.mutate() + } + + const trigger = ( + + ) + + if (isMobile) { + return ( + <> + {trigger} + setOpen(false)} ariaLabel="Reports"> + + + setOpen(false)} + showTitle={false} + /> + + + + ) + } + + return ( + + {trigger} + + setOpen(false)} + /> + + + ) +} diff --git a/frontend/src/components/schedules/JobsTab.tsx b/frontend/src/components/schedules/JobsTab.tsx deleted file mode 100644 index 8dc9d3649..000000000 --- a/frontend/src/components/schedules/JobsTab.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { ScheduleJob } from '@opencode-manager/shared/types' -import { Badge } from '@/components/ui/badge' -import { formatScheduleShortLabel, getJobStatusTone } from '@/components/schedules/schedule-utils' -import { Bot, Clock3 } from 'lucide-react' -import { cn } from '@/lib/utils' - -interface JobsTabProps { - jobs: ScheduleJob[] - selectedJobId: number | null - onSelectJob: (id: number) => void -} - -export function JobsTab({ jobs, selectedJobId, onSelectJob }: JobsTabProps) { - return ( -
- {jobs.map((job) => ( - - ))} -
- ) -} diff --git a/frontend/src/components/schedules/PromptsTab.tsx b/frontend/src/components/schedules/PromptsTab.tsx index 16f93a14c..5819562c8 100644 --- a/frontend/src/components/schedules/PromptsTab.tsx +++ b/frontend/src/components/schedules/PromptsTab.tsx @@ -3,12 +3,14 @@ import type { CreatePromptTemplateRequest } from '@opencode-manager/shared/types import type { PromptDialog } from '@/hooks/useScheduleUrlState' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' -import { Loader2, FileText, Plus, Upload } from 'lucide-react' +import { Loader2, FileText, MoreHorizontal, Plus, Upload } from 'lucide-react' +import { formatDistanceToNow } from 'date-fns' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { usePromptTemplates, useDeletePromptTemplate } from '@/hooks/usePromptTemplates' import { parseMarkdownTemplate } from '@/lib/schedules/markdownTemplate' import { PromptTemplateDialog } from './PromptTemplateDialog' +import { ScheduleListToolbar } from './ScheduleListToolbar' import { DeleteDialog } from '@/components/ui/delete-dialog' -import { PromptTemplateCard } from './PromptTab' interface PromptsTabProps { promptDialog: PromptDialog @@ -24,7 +26,15 @@ export function PromptsTab({ promptDialog, templateId, onNew, onEdit, onDelete, const [importValues, setImportValues] = useState | undefined>() const fileInputRef = useRef(null) + const [search, setSearch] = useState('') const { data: templates = [], isLoading } = usePromptTemplates() + const searchTerm = search.trim().toLowerCase() + const visibleTemplates = searchTerm + ? templates.filter((template) => + [template.title, template.description, template.category, template.cadenceHint, template.suggestedName] + .some((field) => field.toLowerCase().includes(searchTerm)), + ) + : templates const deleteMutation = useDeletePromptTemplate() const editingTemplate = templates.find((t) => t.id === templateId) @@ -72,31 +82,23 @@ export function PromptsTab({ promptDialog, templateId, onNew, onEdit, onDelete, return (
-
-

Prompt templates

-
- - -
-
+ + + + -
+
{isLoading ? (
- ) : templates.length === 0 ? ( + ) : visibleTemplates.length === 0 ? (
@@ -119,24 +121,90 @@ export function PromptsTab({ promptDialog, templateId, onNew, onEdit, onDelete,
-

No templates yet

+

{searchTerm ? 'No matching templates' : 'No templates yet'}

- Create prompt templates to reuse across your schedules. + {searchTerm ? 'Try a different search.' : 'Create prompt templates to reuse across your schedules.'}

) : ( -
- {templates.map((template) => ( - onEdit(item.id)} - onDelete={onDelete} - /> - ))} +
+ + + + + + + + + + + + {visibleTemplates.map((template) => ( + onEdit(template.id)} + className="cursor-pointer transition-colors hover:bg-accent/40" + > + + + + + + + ))} + +
TemplateCategoryCadenceUpdatedActions
+
+ + {template.description && ( + {template.description} + )} + + {template.category} · {template.cadenceHint} + +
+
+ {template.category} + {template.cadenceHint} + {formatDistanceToNow(template.updatedAt, { addSuffix: true })} + + + + + + event.stopPropagation()}> + onEdit(template.id)}>Edit + + onDelete(template.id)}> + Delete + + + +
)}
diff --git a/frontend/src/components/schedules/RunDetailPanel.test.tsx b/frontend/src/components/schedules/RunDetailPanel.test.tsx index aeeabd47f..3ecf3a724 100644 --- a/frontend/src/components/schedules/RunDetailPanel.test.tsx +++ b/frontend/src/components/schedules/RunDetailPanel.test.tsx @@ -8,12 +8,17 @@ import type { ScheduleRun } from '@opencode-manager/shared/types' const mocks = vi.hoisted(() => ({ getRepo: vi.fn(), + useMarkScheduleRunViewed: vi.fn(), })) vi.mock('@/api/repos', () => ({ getRepo: mocks.getRepo, })) +vi.mock('@/hooks/useSchedules', () => ({ + useMarkScheduleRunViewed: mocks.useMarkScheduleRunViewed, +})) + vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: ({ isOpen, initialSelectedFile }: { isOpen: boolean; initialSelectedFile?: string }) => isOpen ?
: null, @@ -37,6 +42,7 @@ const run: ScheduleRun = { status: 'completed', startedAt: 0, finishedAt: null, + viewedAt: null, createdAt: 0, sessionId: null, sessionTitle: null, @@ -48,7 +54,7 @@ const run: ScheduleRun = { worktreePath: null, } -function renderPanel() { +function renderPanel(activeRun: ScheduleRun | null = run) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -60,7 +66,7 @@ function renderPanel() { { beforeEach(() => { vi.clearAllMocks() + mocks.useMarkScheduleRunViewed.mockReturnValue({ mutate: vi.fn(), isPending: false }) }) it('prevents navigation and opens no sheet while the repo query is pending', async () => { @@ -127,4 +134,24 @@ describe('RunDetailPanel local link handling', () => { expect(docs.getAttribute('target')).toBe('_blank') expect(docs.getAttribute('rel')).toBe('noopener noreferrer') }) + + it('marks an unread completed run viewed and skips running or already-viewed runs', () => { + const completedMutate = vi.fn() + mocks.useMarkScheduleRunViewed.mockReturnValue({ mutate: completedMutate, isPending: false }) + const completed = renderPanel({ ...run, status: 'completed', viewedAt: null }) + expect(completedMutate).toHaveBeenCalledTimes(1) + expect(completedMutate).toHaveBeenCalledWith(1) + completed.unmount() + + const runningMutate = vi.fn() + mocks.useMarkScheduleRunViewed.mockReturnValue({ mutate: runningMutate, isPending: false }) + const running = renderPanel({ ...run, status: 'running', viewedAt: null }) + expect(runningMutate).not.toHaveBeenCalled() + running.unmount() + + const viewedMutate = vi.fn() + mocks.useMarkScheduleRunViewed.mockReturnValue({ mutate: viewedMutate, isPending: false }) + renderPanel({ ...run, status: 'completed', viewedAt: 123 }) + expect(viewedMutate).not.toHaveBeenCalled() + }) }) diff --git a/frontend/src/components/schedules/RunDetailPanel.tsx b/frontend/src/components/schedules/RunDetailPanel.tsx index dd2ad2388..230a37d16 100644 --- a/frontend/src/components/schedules/RunDetailPanel.tsx +++ b/frontend/src/components/schedules/RunDetailPanel.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { Button } from '@/components/ui/button' @@ -9,6 +9,7 @@ import { getRepo } from '@/api/repos' import { getWorkspaceFilePath } from '@/lib/markdownLinks' import { getSessionPath } from '@/lib/navigation' import { getRepoDisplayName } from '@/lib/utils' +import { useMarkScheduleRunViewed } from '@/hooks/useSchedules' import { Loader2 } from 'lucide-react' import type { ScheduleRun } from '@opencode-manager/shared/types' @@ -27,6 +28,21 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel queryKey: ['repo', repoId], queryFn: () => getRepo(repoId), }) + const markRunViewed = useMarkScheduleRunViewed() + const viewedRunIdRef = useRef(null) + + const activeRunId = activeRun?.id ?? null + const activeRunStatus = activeRun?.status ?? null + const activeRunViewedAt = activeRun?.viewedAt ?? null + + useEffect(() => { + if (activeRunId === null) return + if (activeRunStatus !== 'completed' && activeRunStatus !== 'failed') return + if (activeRunViewedAt !== null) return + if (viewedRunIdRef.current === activeRunId) return + viewedRunIdRef.current = activeRunId + markRunViewed.mutate(activeRunId) + }, [activeRunId, activeRunStatus, activeRunViewedAt, markRunViewed]) const handleOpenLocalPath = (linkPath: string) => { if (!repo) return @@ -54,30 +70,26 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel return ( <> -
+
- Log - Assistant Output - {activeRun.status === 'cancelled' ? 'Details' : 'Error'} + Log + Assistant Output + {activeRun.status === 'cancelled' ? 'Details' : 'Error'} -
- {(activeRun.status === 'running' || activeRun.sessionId) && ( -
-
- {sessionId && ( - - )} -
+
+ {sessionId && ( + + )} {activeRun.status === 'running' && ( - )}
- )} +
{selectedRunLoading && !activeRun ? (
diff --git a/frontend/src/components/schedules/RunHistoryCards.tsx b/frontend/src/components/schedules/RunHistoryCards.tsx deleted file mode 100644 index 21cb9351c..000000000 --- a/frontend/src/components/schedules/RunHistoryCards.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useState } from 'react' -import { Badge } from '@/components/ui/badge' -import { History, Loader2, XCircle, CheckCircle2, Ban, ChevronDown, Trash2 } from 'lucide-react' -import type { ScheduleRun } from '@opencode-manager/shared/types' -import { getRunTone } from '@/components/schedules/schedule-utils' -import { RunDetailPanel } from '@/components/schedules/RunDetailPanel' -import { useRepoScheduleRun } from '@/hooks/useSchedules' - -interface RunHistoryCardsProps { - runs: ScheduleRun[] | undefined - runsLoading: boolean - selectedRunId?: number | null - onSelectRun: (id: number) => void - onCancelRun: () => void - cancelRunPending: boolean - onDeleteRun?: (runId: number) => void - deleteRunPending?: boolean -} - -export function RunHistoryCards({ - runs, - runsLoading, - selectedRunId = null, - onSelectRun, - onCancelRun, - cancelRunPending, - onDeleteRun, - deleteRunPending, -}: RunHistoryCardsProps) { - const [expandedRunId, setExpandedRunId] = useState(selectedRunId) - const [syncedSelectedRunId, setSyncedSelectedRunId] = useState(selectedRunId) - if (selectedRunId !== syncedSelectedRunId) { - setSyncedSelectedRunId(selectedRunId) - setExpandedRunId(selectedRunId) - } - const expandedRun = runs?.find((run) => run.id === expandedRunId) - - const { data: runDetail, isLoading } = useRepoScheduleRun( - expandedRun?.repoId, - expandedRun?.jobId ?? null, - expandedRun?.id ?? null - ) - - function getRunStatusIcon(status: ScheduleRun['status']) { - if (status === 'completed') return - if (status === 'failed') return - if (status === 'running') return - return - } - - function handleCardClick(runId: number) { - if (expandedRunId === runId) { - setExpandedRunId(null) - } else { - setExpandedRunId(runId) - onSelectRun(runId) - } - } - - if (runsLoading) { - return ( -
- -
- ) - } - - if (!runs?.length) { - return ( -
-
- -

No runs yet

-

Use Run now to generate the first execution record and log bundle.

-
-
- ) - } - - return ( -
- {runs.map((run) => { - const isExpanded = expandedRunId === run.id - const displayRun = isExpanded && runDetail ? runDetail : run - - return ( -
-
- - {onDeleteRun && run.status !== 'running' && ( - - )} -
- {isExpanded && ( -
- -
- )} -
- ) - })} -
- ) -} diff --git a/frontend/src/components/schedules/RunHistoryTab.tsx b/frontend/src/components/schedules/RunHistoryTab.tsx index dc32b3b9d..b8d92e8f6 100644 --- a/frontend/src/components/schedules/RunHistoryTab.tsx +++ b/frontend/src/components/schedules/RunHistoryTab.tsx @@ -1,17 +1,17 @@ +import { useState } from 'react' import type { ScheduleJob, ScheduleRun } from '@opencode-manager/shared/types' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { History, Loader2, Trash2 } from 'lucide-react' -import { RunHistoryCards, RunDetailPanel } from '@/components/schedules' +import { ScheduleListToolbar, ScheduleRunDrawer, ScheduleRunsTable } from '@/components/schedules' +import { formatRunBranch, getRunStatusLabel, getRunTitle } from './schedule-run-display' interface RunHistoryTabProps { - repoId: number selectedJob: ScheduleJob | undefined runs: ScheduleRun[] | undefined runsLoading: boolean - activeRun: ScheduleRun | null - selectedRunLoading: boolean - onSelectRun: (id: number) => void + runId: number | null + onSelectRun: (id: number | null) => void onCancelRun: () => void cancelRunPending: boolean onClearHistory: () => void @@ -21,13 +21,11 @@ interface RunHistoryTabProps { } export function RunHistoryTab({ - repoId, selectedJob, runs, runsLoading, + runId, onSelectRun, - activeRun, - selectedRunLoading, onCancelRun, cancelRunPending, onClearHistory, @@ -35,14 +33,9 @@ export function RunHistoryTab({ onDeleteRun, deleteRunPending, }: RunHistoryTabProps) { + const [search, setSearch] = useState('') + if (!selectedJob) { - if (selectedRunLoading) { - return ( -
- -
- ) - } return (
@@ -56,43 +49,54 @@ export function RunHistoryTab({ ) } + const searchTerm = search.trim().toLowerCase() + const runList = (runs ?? []).filter((run) => !searchTerm || [ + getRunTitle(run), + getRunStatusLabel(run.status), + run.triggerSource, + formatRunBranch(run), + run.errorText, + ].some((field) => field?.toLowerCase().includes(searchTerm))) + const activeRun = runId !== null ? runs?.find((run) => run.id === runId) ?? null : null + const activeIndex = activeRun ? runList.findIndex((run) => run.id === activeRun.id) : -1 + return (
-
-

Run history

+ -
-
-
- +
+
+ -
- -
-
+ onSelectRun(null)} + onCancelRun={onCancelRun} + cancelPending={cancelRunPending} + onPrev={activeIndex > 0 ? () => onSelectRun(runList[activeIndex - 1].id) : undefined} + onNext={activeIndex >= 0 && activeIndex < runList.length - 1 ? () => onSelectRun(runList[activeIndex + 1].id) : undefined} + />
) } diff --git a/frontend/src/components/schedules/ScheduleJobsTable.test.tsx b/frontend/src/components/schedules/ScheduleJobsTable.test.tsx new file mode 100644 index 000000000..b14910812 --- /dev/null +++ b/frontend/src/components/schedules/ScheduleJobsTable.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest' +import type { ComponentProps } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ScheduleJobsTable } from './ScheduleJobsTable' +import type { ScheduleJobWithRepo, ScheduleRunSummary } from '@/api/schedules' + +function makeJob(overrides: Partial = {}): ScheduleJobWithRepo { + return { + id: 1, + repoId: 42, + name: 'Weekly summary', + description: 'Summarize the week', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Summarize the week', + model: null, + skillMetadata: null, + permissionConfig: null, + mcpServers: [], + branch: null, + createdAt: 0, + updatedAt: 0, + lastRunAt: null, + nextRunAt: null, + repoName: 'my-repo', + repoPath: '/home/user/my-repo', + repoUrl: 'https://example.com/my-repo', + lastRun: null, + ...overrides, + } +} + +function makeRun(overrides: Partial = {}): ScheduleRunSummary { + return { + id: 9, + status: 'completed', + startedAt: Date.now() - 60_000, + finishedAt: Date.now() - 30_000, + viewedAt: Date.now(), + preview: null, + ...overrides, + } +} + +function renderTable(jobs: ScheduleJobWithRepo[], props: Partial> = {}) { + return render( + , + ) +} + +describe('ScheduleJobsTable', () => { + it('renders a semantic table with column headers', () => { + renderTable([makeJob()]) + + expect(screen.getByRole('table')).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Status' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Job' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Repo' })).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'Last result' })).toBeInTheDocument() + }) + + it('shows the NEW pill for an unread completed run', () => { + renderTable([makeJob({ lastRun: makeRun({ status: 'completed', viewedAt: null }) })]) + + expect(screen.getAllByText('NEW').length).toBeGreaterThan(0) + }) + + it('styles a failed preview with destructive text', () => { + renderTable([makeJob({ lastRun: makeRun({ status: 'failed', viewedAt: 123, preview: 'Boom' }) })]) + + for (const preview of screen.getAllByText('Boom')) { + expect(preview).toHaveClass('text-destructive/80') + } + }) + + it('shows a Cancel button for a running job', () => { + renderTable( + [makeJob({ lastRun: makeRun({ status: 'running', finishedAt: null, viewedAt: null }) })], + { onCancelRun: vi.fn() }, + ) + + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('shows Resume and the Paused label for a paused job', () => { + renderTable([makeJob({ enabled: false })], { onToggleEnabled: vi.fn() }) + + expect(screen.getByRole('button', { name: 'Resume' })).toBeInTheDocument() + expect(screen.getByText('Paused')).toBeInTheDocument() + }) + + it('renders only the provided actions in the menu with Delete last', async () => { + const user = userEvent.setup() + renderTable([makeJob()], { onRunNow: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn() }) + + await user.click(screen.getByRole('button', { name: 'More actions' })) + + const items = await screen.findAllByRole('menuitem') + expect(items.map((item) => item.textContent)).toEqual(['Run now', 'Edit', 'Delete']) + expect(items.at(-1)).toHaveClass('text-destructive') + }) + + it('calls onOpen when a row is clicked', async () => { + const user = userEvent.setup() + const onOpen = vi.fn() + renderTable([makeJob()], { onOpen }) + + await user.click(screen.getByText('Weekly summary')) + + expect(onOpen).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/components/schedules/ScheduleJobsTable.tsx b/frontend/src/components/schedules/ScheduleJobsTable.tsx new file mode 100644 index 000000000..52bde8850 --- /dev/null +++ b/frontend/src/components/schedules/ScheduleJobsTable.tsx @@ -0,0 +1,259 @@ +import type { MouseEvent } from 'react' +import { formatDistanceToNow } from 'date-fns' +import { Loader2, MoreHorizontal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { formatScheduleShortLabel, formatScheduleSummary } from './schedule-utils' +import type { ScheduleJobWithRepo, ScheduleRunSummary } from '@/api/schedules' + +interface ScheduleJobsTableProps { + jobs: ScheduleJobWithRepo[] + showRepo: boolean + selectedJobId?: number | null + onOpen: (job: ScheduleJobWithRepo) => void + onRunNow?: (job: ScheduleJobWithRepo) => void + onToggleEnabled?: (job: ScheduleJobWithRepo) => void + onEdit?: (job: ScheduleJobWithRepo) => void + onDelete?: (job: ScheduleJobWithRepo) => void + onCancelRun?: (job: ScheduleJobWithRepo) => void + onOpenJob?: (job: ScheduleJobWithRepo) => void + onNavigateToRepo?: (repoPath: string) => void + runPending?: boolean + cancelPending?: boolean +} + +function isUnreadRun(lastRun: ScheduleRunSummary): boolean { + return lastRun.viewedAt === null && (lastRun.status === 'completed' || lastRun.status === 'failed') +} + +function ScheduleJobNewPill() { + return ( + + NEW + + ) +} + +function ScheduleJobStatusIndicator({ job }: { job: ScheduleJobWithRepo }) { + const status = job.lastRun?.status + + if (status === 'running') { + return