diff --git a/backend/src/Taskdeck.Api/Contracts/DismissProposalsRequest.cs b/backend/src/Taskdeck.Api/Contracts/DismissProposalsRequest.cs new file mode 100644 index 000000000..c28a2b8bd --- /dev/null +++ b/backend/src/Taskdeck.Api/Contracts/DismissProposalsRequest.cs @@ -0,0 +1,9 @@ +namespace Taskdeck.Api.Contracts; + +/// +/// Request body for the batch dismiss proposals endpoint. +/// +public sealed class DismissProposalsRequest +{ + public IReadOnlyList? Ids { get; set; } +} diff --git a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs index 3e23dfbc2..212b8eea9 100644 --- a/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs @@ -215,6 +215,51 @@ public async Task ExecuteProposal(Guid id, CancellationToken canc return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + /// + /// Dismisses completed proposals so they no longer appear in the default review list. + /// Accepts an array of proposal IDs; only proposals in terminal states (Applied, Rejected, Failed, Expired) will be dismissed. + /// + [HttpPost("dismiss")] + public async Task DismissProposals( + [FromBody] DismissProposalsRequest request, + CancellationToken cancellationToken = default) + { + if (!TryGetCurrentUserId(out var callerUserId, out var errorResult)) + return errorResult!; + + if (request.Ids is null || request.Ids.Count == 0) + { + return BadRequest(new ApiErrorResponse( + ErrorCodes.ValidationError, + "At least one proposal ID is required")); + } + + if (request.Ids.Count > MaxProposalListLimit) + { + return BadRequest(new ApiErrorResponse( + ErrorCodes.ValidationError, + $"Cannot dismiss more than {MaxProposalListLimit} proposals at once")); + } + + // Verify the caller owns each proposal being dismissed + foreach (var proposalId in request.Ids.Distinct()) + { + var proposalResult = await _proposalService.GetProposalByIdAsync(proposalId, cancellationToken); + if (!proposalResult.IsSuccess) + return proposalResult.ToErrorActionResult(); + + if (proposalResult.Value.RequestedByUserId != callerUserId) + { + return Result.Failure(ErrorCodes.Forbidden, "You can only dismiss your own proposals.").ToErrorActionResult(); + } + } + + var result = await _proposalService.DismissProposalsAsync(request.Ids, cancellationToken); + return result.IsSuccess + ? Ok(new { dismissed = result.Value }) + : result.ToErrorActionResult(); + } + /// /// Gets a diff preview for a proposal showing what changes will be made. /// diff --git a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs index 912e6df0b..d6706953e 100644 --- a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs +++ b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs @@ -289,6 +289,38 @@ public async Task> GetProposalDiffAsync(Guid id, CancellationToke return Result.Success(generatedDiff); } + public async Task> DismissProposalsAsync(IReadOnlyList ids, CancellationToken cancellationToken = default) + { + if (ids.Count == 0) + return Result.Success(0); + + try + { + var proposals = await _unitOfWork.AutomationProposals.GetByIdsAsync(ids, cancellationToken); + int dismissed = 0; + + foreach (var proposal in proposals) + { + if (proposal.Status is ProposalStatus.Applied or ProposalStatus.Rejected + or ProposalStatus.Failed or ProposalStatus.Expired) + { + proposal.Dismiss(); + dismissed++; + } + // Skip proposals not in a dismissible terminal state + } + + if (dismissed > 0) + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Result.Success(dismissed); + } + catch (DomainException ex) + { + return Result.Failure(ex.ErrorCode, ex.Message); + } + } + private static ProposalDto MapToDto(AutomationProposal proposal) { return new ProposalDto( diff --git a/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs index b3691d489..ee1972867 100644 --- a/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs +++ b/backend/src/Taskdeck.Application/Services/IAutomationProposalService.cs @@ -50,4 +50,9 @@ public interface IAutomationProposalService /// Gets the diff preview for a proposal. /// Task> GetProposalDiffAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// Dismisses completed proposals (Applied, Rejected, Failed, Expired) so they no longer appear in the default review list. + /// + Task> DismissProposalsAsync(IReadOnlyList ids, CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs index 9746ebfa5..18ac9cf2c 100644 --- a/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs +++ b/backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs @@ -131,6 +131,16 @@ public void Expire() Touch(); } + public void Dismiss() + { + if (Status != ProposalStatus.Applied && Status != ProposalStatus.Rejected + && Status != ProposalStatus.Failed && Status != ProposalStatus.Expired) + throw new DomainException(ErrorCodes.InvalidOperation, $"Cannot dismiss proposal in status {Status}"); + + Status = ProposalStatus.Dismissed; + Touch(); + } + public void SetDiffPreview(string diffPreview) { if (Status != ProposalStatus.PendingReview) @@ -164,7 +174,8 @@ public enum ProposalStatus Rejected, Applied, Failed, - Expired + Expired, + Dismissed } public enum RiskLevel diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs index 09a0d965a..2c38b3307 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/AutomationProposalRepository.cs @@ -67,7 +67,6 @@ public async Task> GetByIdsAsync(IEnumerable uniqueIds.Contains(proposal.Id)) .ToListAsync(cancellationToken); } diff --git a/frontend/taskdeck-web/src/api/automationApi.ts b/frontend/taskdeck-web/src/api/automationApi.ts index 3512082df..6421e7d7e 100644 --- a/frontend/taskdeck-web/src/api/automationApi.ts +++ b/frontend/taskdeck-web/src/api/automationApi.ts @@ -40,4 +40,9 @@ export const automationApi = { const { data } = await http.get<{ diff: string }>(`/automation/proposals/${encodeURIComponent(id)}/diff`) return data.diff }, + + async dismissProposals(ids: string[]): Promise<{ dismissed: number }> { + const { data } = await http.post<{ dismissed: number }>('/automation/proposals/dismiss', { ids }) + return data + }, } diff --git a/frontend/taskdeck-web/src/tests/views/ReviewView.spec.ts b/frontend/taskdeck-web/src/tests/views/ReviewView.spec.ts index a1364f21a..6315cc7ce 100644 --- a/frontend/taskdeck-web/src/tests/views/ReviewView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/ReviewView.spec.ts @@ -237,12 +237,20 @@ describe('ReviewView', () => { const { wrapper } = await mountAt('/workspace/review') + // Actionable proposals visible by default expect(wrapper.text()).toContain('Review required') expect(wrapper.text()).toContain('Approved, ready to apply') - expect(wrapper.text()).toContain('Applied to board') expect(wrapper.text()).toContain('Approve for board') expect(wrapper.text()).toContain('Apply to board') expect(wrapper.text()).toContain('Changes stay in review until you approve them.') + + // Applied proposals hidden by default (showCompleted is off) + expect(wrapper.text()).not.toContain('Applied to board') + + // Toggle showCompleted on to reveal applied proposals + const toggle = wrapper.find('.td-review__toggle-input') + await toggle.setValue(true) + expect(wrapper.text()).toContain('Applied to board') }) it('renders capture provenance and canonical review links', async () => { diff --git a/frontend/taskdeck-web/src/types/automation.ts b/frontend/taskdeck-web/src/types/automation.ts index e5311ec35..36830fe22 100644 --- a/frontend/taskdeck-web/src/types/automation.ts +++ b/frontend/taskdeck-web/src/types/automation.ts @@ -1,6 +1,6 @@ export type ProposalSourceType = 'Queue' | 'Chat' | 'Manual' export type ProposalSourceTypeValue = ProposalSourceType | number -export type ProposalStatus = 'PendingReview' | 'Approved' | 'Rejected' | 'Applied' | 'Failed' | 'Expired' +export type ProposalStatus = 'PendingReview' | 'Approved' | 'Rejected' | 'Applied' | 'Failed' | 'Expired' | 'Dismissed' export type ProposalStatusValue = ProposalStatus | number export type ProposalRiskLevel = 'Low' | 'Medium' | 'High' | 'Critical' export type ProposalRiskLevelValue = ProposalRiskLevel | number diff --git a/frontend/taskdeck-web/src/utils/automation.ts b/frontend/taskdeck-web/src/utils/automation.ts index ea7029e79..1f4395e96 100644 --- a/frontend/taskdeck-web/src/utils/automation.ts +++ b/frontend/taskdeck-web/src/utils/automation.ts @@ -1,6 +1,6 @@ import type { ProposalRiskLevelValue, ProposalSourceTypeValue, ProposalStatusValue } from '../types/automation' -const proposalStatusByIndex = ['PendingReview', 'Approved', 'Rejected', 'Applied', 'Failed', 'Expired'] as const +const proposalStatusByIndex = ['PendingReview', 'Approved', 'Rejected', 'Applied', 'Failed', 'Expired', 'Dismissed'] as const const proposalSourceByIndex = ['Queue', 'Chat', 'Manual'] as const const proposalRiskByIndex = ['Low', 'Medium', 'High', 'Critical'] as const diff --git a/frontend/taskdeck-web/src/views/ReviewView.vue b/frontend/taskdeck-web/src/views/ReviewView.vue index e4c4a8c41..8fd38f1d2 100644 --- a/frontend/taskdeck-web/src/views/ReviewView.vue +++ b/frontend/taskdeck-web/src/views/ReviewView.vue @@ -43,6 +43,9 @@ const availableBoards = ref([]) const loadingBoards = ref(false) const boardFilterInput = ref('') const activeBoardFilter = computed(() => normalizeBoardIdQueryParam(route.query.boardId)) +const showCompleted = ref(false) + +const completedStatuses = new Set(['Applied', 'Rejected', 'Failed', 'Expired', 'Dismissed']) const boardOptions = computed(() => buildInputAssistOptions( @@ -71,7 +74,19 @@ function matchesActiveBoardFilter(boardId: string | null | undefined): boolean { return normalizedBoardId === activeBoardFilter.value.toLowerCase() } -const visibleProposals = computed(() => proposals.value.filter((proposal) => matchesActiveBoardFilter(proposal.boardId))) +const visibleProposals = computed(() => + proposals.value.filter((proposal) => { + if (!matchesActiveBoardFilter(proposal.boardId)) return false + + // Always hide dismissed proposals + if (normalizeProposalStatus(proposal.status) === 'Dismissed') return false + + // When showCompleted is off, hide terminal-state proposals + if (!showCompleted.value && completedStatuses.has(normalizeProposalStatus(proposal.status))) return false + + return true + }), +) const summaryCards = computed(() => { let pendingReview = 0 @@ -471,6 +486,7 @@ const statusLabels: Record = { Rejected: 'Rejected', Failed: 'Failed', Expired: 'Expired', + Dismissed: 'Dismissed', } function reviewStatusLabel(status: ApiProposal['status']): string { @@ -499,6 +515,34 @@ function applyBoardFilter(boardId: string) { } } +const dismissableProposalIds = computed(() => + proposals.value + .filter((p) => { + const status = normalizeProposalStatus(p.status) + return status === 'Applied' || status === 'Rejected' || status === 'Failed' || status === 'Expired' + }) + .filter((p) => matchesActiveBoardFilter(p.boardId)) + .map((p) => p.id), +) + +async function handleDismissApplied() { + const ids = dismissableProposalIds.value + if (ids.length === 0) { + toast.info('No completed proposals to clear.') + return + } + + try { + const result = await automationApi.dismissProposals(ids) + // Remove dismissed proposals from the local list + const dismissedSet = new Set(ids) + proposals.value = proposals.value.filter((p) => !dismissedSet.has(p.id)) + toast.success(`Cleared ${result.dismissed} completed proposal${result.dismissed === 1 ? '' : 's'}.`) + } catch (e: unknown) { + toast.error(getErrorDisplay(e, 'Failed to clear proposals').message) + } +} + function clearBoardFilter() { boardFilterInput.value = '' void router.push({ name: 'workspace-review' }) @@ -552,6 +596,17 @@ watch(
+ + @@ -774,6 +829,29 @@ watch( flex-wrap: wrap; gap: var(--td-space-2); justify-content: flex-end; + align-items: center; +} + +.td-review__toggle { + display: flex; + align-items: center; + gap: var(--td-space-2); + cursor: pointer; + user-select: none; +} + +.td-review__toggle-input { + accent-color: var(--td-color-primary); + width: 16px; + height: 16px; + cursor: pointer; +} + +.td-review__toggle-label { + font-size: var(--td-font-sm); + font-weight: 600; + color: var(--td-text-secondary); + white-space: nowrap; } .td-review__summary { diff --git a/frontend/taskdeck-web/tests/e2e/automation-ops.spec.ts b/frontend/taskdeck-web/tests/e2e/automation-ops.spec.ts index 5cb07aba1..350e84262 100644 --- a/frontend/taskdeck-web/tests/e2e/automation-ops.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/automation-ops.spec.ts @@ -1,10 +1,10 @@ -import type { APIRequestContext } from '@playwright/test' -import { expect, test } from '@playwright/test' -import { parseTrueishEnv } from '../../scripts/demo-shared.mjs' -import { API_BASE_URL, registerAndAttachSession, type AuthResult } from './support/authSession' -import { createBoardWithColumn } from './support/boardHelpers' -import { assertOk } from './support/httpAsserts' -import { pollUntil } from './support/polling' +import type { APIRequestContext } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { parseTrueishEnv } from '../../scripts/demo-shared.mjs' +import { API_BASE_URL, registerAndAttachSession, type AuthResult } from './support/authSession' +import { createBoardWithColumn } from './support/boardHelpers' +import { assertOk } from './support/httpAsserts' +import { pollUntil } from './support/polling' interface ChatMessageDto { proposalId: string | null @@ -50,28 +50,28 @@ test.beforeEach(async ({ page, request }) => { auth = await registerAndAttachSession(page, request, 'ops') }) -test('chat session should create and return assistant response', async ({ page }) => { - await page.goto('/workspace/automations/chat') - const expectLiveProvider = parseTrueishEnv(process.env.TASKDECK_RUN_LIVE_LLM_TESTS) - - if (expectLiveProvider) { - await expect(page.locator('[data-llm-health-state="configured"]')).toBeVisible() - await expect(page.getByText('Live LLM configured')).toBeVisible() - } else { - await expect(page.locator('[data-llm-health-state="mock"]')).toBeVisible() - await expect(page.getByText('Live LLM not active')).toBeVisible() - } - - await page.getByPlaceholder('Session title').fill(`Session ${Date.now()}`) - await page.getByRole('button', { name: 'Create Session' }).click() +test('chat session should create and return assistant response', async ({ page }) => { + await page.goto('/workspace/automations/chat') + const expectLiveProvider = parseTrueishEnv(process.env.TASKDECK_RUN_LIVE_LLM_TESTS) + + if (expectLiveProvider) { + await expect(page.locator('[data-llm-health-state="configured"]')).toBeVisible() + await expect(page.getByText('Live LLM configured')).toBeVisible() + } else { + await expect(page.locator('[data-llm-health-state="mock"]')).toBeVisible() + await expect(page.getByText('Live LLM not active')).toBeVisible() + } + + await page.getByPlaceholder('Session title').fill(`Session ${Date.now()}`) + await page.getByRole('button', { name: 'Create Session' }).click() await expect(page.getByText('Session', { exact: false }).first()).toBeVisible() await page.getByPlaceholder('Describe an automation instruction...').fill('summarize this board status') await page.getByRole('button', { name: 'Send Message' }).click() - - await expect(page.getByText('Assistant').first()).toBeVisible() -}) + + await expect(page.getByText('Assistant').first()).toBeVisible() +}) test('ops cli should run health.check template', async ({ page }) => { await page.goto('/workspace/ops/cli') @@ -120,7 +120,7 @@ test('chat proposal flow should create, approve, and execute proposal', async ({ const proposal = await proposalResponse.json() as ProposalDto await page.goto('/workspace/review') - await expect(page.getByRole('heading', { name: 'Review', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Review', exact: true })).toBeVisible() const proposalCard = page.locator('.td-review-card').filter({ hasText: proposal.summary }).first() await expect(proposalCard).toBeVisible() @@ -130,5 +130,5 @@ test('chat proposal flow should create, approve, and execute proposal', async ({ page.once('dialog', (dialog) => dialog.accept()) await proposalCard.getByRole('button', { name: 'Apply to board' }).click() - await expect(proposalCard.getByText('Applied')).toBeVisible() + await expect(proposalCard).not.toBeVisible() }) diff --git a/frontend/taskdeck-web/tests/e2e/capture-loop.spec.ts b/frontend/taskdeck-web/tests/e2e/capture-loop.spec.ts index e68f715ef..45fe0eea6 100644 --- a/frontend/taskdeck-web/tests/e2e/capture-loop.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/capture-loop.spec.ts @@ -59,7 +59,7 @@ test('capture triage should create proposal and apply card with provenance links page.once('dialog', (dialog) => dialog.accept()) await proposalCard.getByRole('button', { name: 'Apply to board' }).click() - await expect(proposalCard.getByText('Applied')).toBeVisible() + await expect(proposalCard).not.toBeVisible() const createdCard = await waitForCardWithTitle(request, auth, boardId, checklistTaskTitle) diff --git a/frontend/taskdeck-web/tests/e2e/first-run.spec.ts b/frontend/taskdeck-web/tests/e2e/first-run.spec.ts index f62abfd1f..a3cebe022 100644 --- a/frontend/taskdeck-web/tests/e2e/first-run.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/first-run.spec.ts @@ -132,10 +132,13 @@ test('first-run path should guide home to capture to review to execute to board' page.once('dialog', (dialog) => dialog.accept()) await proposalCard.getByRole('button', { name: 'Apply to board' }).click() - await expect(proposalCard.getByText('Applied')).toBeVisible() + await expect(proposalCard).not.toBeVisible() const createdCard = await waitForCardWithTitle(request, auth, boardId, cardTitle) + // Toggle "Show completed" to reveal applied proposal and its Open Board button + await page.locator('.td-review__toggle-input').check() + await expect(proposalCard).toBeVisible() await proposalCard.getByRole('button', { name: 'Open Board' }).click() await expect(page).toHaveURL(new RegExp(`/workspace/boards/${boardId}$`)) const card = page.locator('[data-card-id]').filter({ hasText: createdCard.title }).first() diff --git a/frontend/taskdeck-web/tests/e2e/manual-audit.spec.ts b/frontend/taskdeck-web/tests/e2e/manual-audit.spec.ts index e64666726..06f6ce5f0 100644 --- a/frontend/taskdeck-web/tests/e2e/manual-audit.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/manual-audit.spec.ts @@ -109,7 +109,7 @@ test.describe('Core loop: Home -> Inbox/Capture -> Review -> Board', () => { // Step 9: Apply proposal to board page.once('dialog', (dialog) => dialog.accept()) await proposalCard.getByRole('button', { name: 'Apply to board' }).click() - await expect(proposalCard.getByText('Applied')).toBeVisible() + await expect(proposalCard).not.toBeVisible() await page.screenshot({ path: testInfo.outputPath('06-review-applied.png'), fullPage: true }) // Step 10: Verify card on board