From 5a8a81419ca18cbe509c3c4cdf1ae4beabe00d5a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:35:10 -0400 Subject: [PATCH 1/3] feat(schedules): add unread run report notifications and run search --- .../migrations/024-schedule-runs-viewed-at.ts | 36 ++ backend/src/db/migrations/index.ts | 2 + backend/src/db/schedules.ts | 160 +++++- backend/src/routes/schedules.ts | 33 +- backend/src/services/schedules.ts | 22 +- backend/test/db/schedule-migrations.test.ts | 51 ++ backend/test/db/schedules.test.ts | 179 +++++++ backend/test/routes/schedules.test.ts | 61 +++ .../services/schedules.permission.test.ts | 1 + backend/test/services/schedules.test.ts | 1 + frontend/src/api/schedules.ts | 39 +- .../navigation/MobileTabBar.test.tsx | 4 + .../components/navigation/MobileTabBar.tsx | 9 +- .../notifications/PendingActionsGroup.tsx | 2 + .../ScheduleReportsBell.test.tsx | 65 +++ .../notifications/ScheduleReportsBell.tsx | 182 +++++++ frontend/src/components/schedules/JobsTab.tsx | 43 -- .../src/components/schedules/PromptsTab.tsx | 146 ++++-- .../schedules/RunDetailPanel.test.tsx | 31 +- .../components/schedules/RunDetailPanel.tsx | 48 +- .../components/schedules/RunHistoryCards.tsx | 158 ------ .../components/schedules/RunHistoryTab.tsx | 74 +-- .../schedules/ScheduleJobsTable.test.tsx | 122 +++++ .../schedules/ScheduleJobsTable.tsx | 259 ++++++++++ .../schedules/ScheduleListToolbar.tsx | 45 ++ .../schedules/ScheduleRunDrawer.test.tsx | 125 +++++ .../schedules/ScheduleRunDrawer.tsx | 131 +++++ .../schedules/ScheduleRunsTable.test.tsx | 119 +++++ .../schedules/ScheduleRunsTable.tsx | 144 ++++++ .../schedules/__tests__/PromptsTab.test.tsx | 37 +- .../__tests__/schedule-utils.test.ts | 30 ++ frontend/src/components/schedules/index.ts | 6 +- .../schedules/schedule-run-display.tsx | 33 ++ .../components/schedules/schedule-utils.ts | 29 +- frontend/src/components/ui/side-drawer.tsx | 4 +- frontend/src/hooks/useDebouncedValue.ts | 13 + frontend/src/hooks/useFileSearch.ts | 9 +- frontend/src/hooks/useSchedules.ts | 45 +- frontend/src/pages/GlobalSchedules.tsx | 455 +++++------------- frontend/src/pages/Schedules.tsx | 101 ++-- .../pages/__tests__/GlobalSchedules.test.tsx | 155 ++++-- .../src/pages/__tests__/Schedules.test.tsx | 51 +- shared/src/schemas/schedule.ts | 1 + 43 files changed, 2463 insertions(+), 798 deletions(-) create mode 100644 backend/src/db/migrations/024-schedule-runs-viewed-at.ts create mode 100644 frontend/src/components/notifications/ScheduleReportsBell.test.tsx create mode 100644 frontend/src/components/notifications/ScheduleReportsBell.tsx delete mode 100644 frontend/src/components/schedules/JobsTab.tsx delete mode 100644 frontend/src/components/schedules/RunHistoryCards.tsx create mode 100644 frontend/src/components/schedules/ScheduleJobsTable.test.tsx create mode 100644 frontend/src/components/schedules/ScheduleJobsTable.tsx create mode 100644 frontend/src/components/schedules/ScheduleListToolbar.tsx create mode 100644 frontend/src/components/schedules/ScheduleRunDrawer.test.tsx create mode 100644 frontend/src/components/schedules/ScheduleRunDrawer.tsx create mode 100644 frontend/src/components/schedules/ScheduleRunsTable.test.tsx create mode 100644 frontend/src/components/schedules/ScheduleRunsTable.tsx create mode 100644 frontend/src/components/schedules/__tests__/schedule-utils.test.ts create mode 100644 frontend/src/components/schedules/schedule-run-display.tsx create mode 100644 frontend/src/hooks/useDebouncedValue.ts 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/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/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..32fc48211 --- /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..89a8d7e4e 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,88 @@ 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)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onEdit(template.id) + } + }} + className="cursor-pointer transition-colors hover:bg-accent/40" + > + + + + + + + ))} + +
TemplateCategoryCadenceUpdatedActions
+
+ {template.title} + {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..4e18e42c5 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 ? runList.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..eae898765 --- /dev/null +++ b/frontend/src/components/schedules/ScheduleJobsTable.tsx @@ -0,0 +1,259 @@ +import type { KeyboardEvent, 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