Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/src/Taskdeck.Api/Contracts/DismissProposalsRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Taskdeck.Api.Contracts;

/// <summary>
/// Request body for the batch dismiss proposals endpoint.
/// </summary>
public sealed class DismissProposalsRequest
{
public IReadOnlyList<Guid>? Ids { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,51 @@ public async Task<IActionResult> ExecuteProposal(Guid id, CancellationToken canc
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}

/// <summary>
/// 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.
/// </summary>
[HttpPost("dismiss")]
public async Task<IActionResult> 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();
}

/// <summary>
/// Gets a diff preview for a proposal showing what changes will be made.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,38 @@ public async Task<Result<string>> GetProposalDiffAsync(Guid id, CancellationToke
return Result.Success(generatedDiff);
}

public async Task<Result<int>> DismissProposalsAsync(IReadOnlyList<Guid> 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<int>(ex.ErrorCode, ex.Message);
}
}

private static ProposalDto MapToDto(AutomationProposal proposal)
{
return new ProposalDto(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,9 @@ public interface IAutomationProposalService
/// Gets the diff preview for a proposal.
/// </summary>
Task<Result<string>> GetProposalDiffAsync(Guid id, CancellationToken cancellationToken = default);

/// <summary>
/// Dismisses completed proposals (Applied, Rejected, Failed, Expired) so they no longer appear in the default review list.
/// </summary>
Task<Result<int>> DismissProposalsAsync(IReadOnlyList<Guid> ids, CancellationToken cancellationToken = default);
}
13 changes: 12 additions & 1 deletion backend/src/Taskdeck.Domain/Entities/AutomationProposal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -164,7 +174,8 @@ public enum ProposalStatus
Rejected,
Applied,
Failed,
Expired
Expired,
Dismissed
}

public enum RiskLevel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ public async Task<IReadOnlyList<AutomationProposal>> GetByIdsAsync(IEnumerable<G
}

return await _dbSet
.AsNoTracking()
.Where(proposal => uniqueIds.Contains(proposal.Id))
.ToListAsync(cancellationToken);
}
Expand Down
5 changes: 5 additions & 0 deletions frontend/taskdeck-web/src/api/automationApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
10 changes: 9 additions & 1 deletion frontend/taskdeck-web/src/tests/views/ReviewView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/taskdeck-web/src/types/automation.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion frontend/taskdeck-web/src/utils/automation.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
80 changes: 79 additions & 1 deletion frontend/taskdeck-web/src/views/ReviewView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const availableBoards = ref<Board[]>([])
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(
Expand Down Expand Up @@ -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
Comment on lines +82 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a redundancy in the filtering logic. Since Dismissed is already included in the completedStatuses set (line 48), the explicit check on line 82 is only necessary if the intention is to hide dismissed proposals even when showCompleted is toggled on. If the intention is that "Show completed" should show everything except dismissed items, the code is correct but could be simplified by clarifying the set usage.


return true
}),
)

const summaryCards = computed<ReviewSummaryCard[]>(() => {
let pendingReview = 0
Expand Down Expand Up @@ -471,6 +486,7 @@ const statusLabels: Record<string, string> = {
Rejected: 'Rejected',
Failed: 'Failed',
Expired: 'Expired',
Dismissed: 'Dismissed',
}

function reviewStatusLabel(status: ApiProposal['status']): string {
Expand Down Expand Up @@ -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' })
Expand Down Expand Up @@ -552,6 +596,17 @@ watch(
</div>

<div class="td-review__hero-actions">
<label class="td-review__toggle">
<input v-model="showCompleted" type="checkbox" class="td-review__toggle-input" />
<span class="td-review__toggle-label">Show completed</span>
</label>
<button
class="td-btn td-btn--secondary"
:disabled="dismissableProposalIds.length === 0"
@click="handleDismissApplied"
>
Clear applied ({{ dismissableProposalIds.length }})
</button>
<button class="td-btn td-btn--primary" :disabled="proposalsLoading" @click="loadProposals">
{{ proposalsLoading ? 'Refreshing...' : 'Refresh Review' }}
</button>
Expand Down Expand Up @@ -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 {
Expand Down
52 changes: 26 additions & 26 deletions frontend/taskdeck-web/tests/e2e/automation-ops.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()
Expand All @@ -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()
})
Loading
Loading