diff --git a/.codex/memories/00_ACTIVE.md b/.codex/memories/00_ACTIVE.md index 59ddedba1..c778da78a 100644 --- a/.codex/memories/00_ACTIVE.md +++ b/.codex/memories/00_ACTIVE.md @@ -30,7 +30,7 @@ Taskdeck ships as a free open beta: the local-first, review-first action-item en ## Context Fabric pointer (ADR-0065, accepted under delegation; NOT part of the v0.3 lane) -The architecture for "speak, type, paste, or drop" is `docs/decisions/ADR-0065-context-fabric-capture-representation-processing.md`, mapped in `docs/architecture/CONTEXT_FABRIC.md`, tracked on CF-00 `#2254` (children `#2255`-`#2277`, label `context-fabric`, milestones v0.4 foundation / v0.5 payoff / v0.6 rules). PR `#2280` and the reconciliation pass PR `#2320` (SourceAsset foundation, three capture state axes, Worker Protocol v1-alpha, IBlobStore reference semantics, canonical `CaptureIntakeService`) are both merged, so the reconciled contracts are on `main`. Build on those, not on #2280's originals. Do not pull CF issues into the v0.3 lane; do not add `CaptureSource` values or request-type lane predicates anywhere; do not build CF-22 (delegated authority) without its own maintainer go. Review-first automation is unchanged. +The architecture for "speak, type, paste, or drop" is `docs/decisions/ADR-0065-context-fabric-capture-representation-processing.md`, mapped in `docs/architecture/CONTEXT_FABRIC.md`, tracked on CF-00 `#2254` (children `#2255`-`#2277`, label `context-fabric`, milestones v0.4 foundation / v0.5 payoff / v0.6 rules). The current build base is **PR `#2417`** (merge `eaa996fa2`, 2026-09-03), which reconciles capture text before a disposition stamp lands. It sits on PR `#2344` (merge `a6cc459c9`, CF-01 durable Capture: ID-preserving backfill, dual-write on, Inbox reads through `ICaptureStore`), which in turn sits on PR `#2280` and the reconciliation pass PR `#2320` (SourceAsset foundation, three capture state axes, Worker Protocol v1-alpha, IBlobStore reference semantics, canonical `CaptureIntakeService`). Build on `#2417`, not on `#2344`, `#2320` or `#2280`'s originals. Do not pull CF issues into the v0.3 lane; do not add `CaptureSource` values or request-type lane predicates anywhere; do not build CF-22 (delegated authority) without its own maintainer go. Review-first automation is unchanged. ## Standing constraints diff --git a/autodoc/AGENT_INDEX.md b/autodoc/AGENT_INDEX.md index 8d013323b..a3cd33ea4 100644 --- a/autodoc/AGENT_INDEX.md +++ b/autodoc/AGENT_INDEX.md @@ -21,7 +21,7 @@ It is the Taskdeck equivalent of the harness `AGENT_MAP.md` (grandfathered name) (`.github/**`, `ci/**`, `scripts/ci/**`) and `.claude/rules/docs.md` (`docs/**`, root `*.md`). Read those, not the whole repo. - Current shipped state: `docs/STATUS.md` (source of truth) — read the relevant section, it is - 935 lines; do not read it end-to-end. Roadmap: `docs/IMPLEMENTATION_MASTERPLAN.md` (2068 + 970 lines; do not read it end-to-end. Roadmap: `docs/IMPLEMENTATION_MASTERPLAN.md` (2068 lines — also section-read only, never bulk-read). Human-action file: `OUTSTANDING_TASKS.md`. Strategy spine: `docs/strategy/PRODUCT_DIRECTION.md` → `docs/REVIVAL_PLAN.md`. Decisions: `docs/decisions/INDEX.md`. diff --git a/backend/src/Taskdeck.Api/Mcp/ProposalResources.cs b/backend/src/Taskdeck.Api/Mcp/ProposalResources.cs index 8cc737b53..d26ba0982 100644 --- a/backend/src/Taskdeck.Api/Mcp/ProposalResources.cs +++ b/backend/src/Taskdeck.Api/Mcp/ProposalResources.cs @@ -40,7 +40,8 @@ public async Task ListProposals() var result = await _proposalService.GetProposalsAsync(new ProposalFilterDto(UserId: userId)); if (!result.IsSuccess) - throw new InvalidOperationException($"MCP: failed to list proposals: {result.ErrorMessage}"); + throw new InvalidOperationException( + $"MCP: failed to list proposals: {PublicFailureMessage(result)}"); var proposals = result.Value.Select(p => new { @@ -77,7 +78,8 @@ public async Task GetProposalDetail(string proposalId) var result = await _proposalService.GetProposalByIdAsync(proposalGuid); if (!result.IsSuccess) - throw new InvalidOperationException($"MCP: failed to get proposal: {result.ErrorMessage}"); + throw new InvalidOperationException( + $"MCP: failed to get proposal: {PublicFailureMessage(result)}"); var p = result.Value; @@ -99,11 +101,10 @@ public async Task GetProposalDetail(string proposalId) ? await _proposalService.GetTerminalProposalStoredPreviewAsync(p.Id) : await _proposalService.GetProposalDiffAsync(p.Id); - // Surface the service's own error message exactly as the GetProposalByIdAsync failure - // above and the MCP write tools do (WriteTools/ProposalTools raise result.ErrorMessage) — - // no new MCP error shape is invented for the gate denial. + // Keep known domain failures specific, but never carry an unexpected service failure's + // raw message across the public MCP boundary. if (!previewResult.IsSuccess) - throw new InvalidOperationException($"MCP: {previewResult.ErrorMessage}"); + throw new InvalidOperationException($"MCP: {PublicFailureMessage(previewResult)}"); var operations = p.Operations.Select(op => new { @@ -146,4 +147,7 @@ or ProposalStatus.Rejected or ProposalStatus.Failed or ProposalStatus.Expired or ProposalStatus.Dismissed; + + private static string PublicFailureMessage(Result result) => + SensitiveDataRedactor.SanitizeLlmFailureMessage(result.ErrorCode, result.ErrorMessage); } diff --git a/backend/src/Taskdeck.Api/Mcp/ProposalTools.cs b/backend/src/Taskdeck.Api/Mcp/ProposalTools.cs index 56db5ee50..39df11d44 100644 --- a/backend/src/Taskdeck.Api/Mcp/ProposalTools.cs +++ b/backend/src/Taskdeck.Api/Mcp/ProposalTools.cs @@ -4,7 +4,9 @@ using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; namespace Taskdeck.Api.Mcp; @@ -46,7 +48,7 @@ public async Task GetProposalStatus( var result = await _proposalService.GetProposalByIdAsync(proposalGuid); if (!result.IsSuccess) - return Error(result.ErrorMessage); + return Error(result); var p = result.Value; @@ -120,7 +122,7 @@ public async Task ListProposals( var result = await _proposalService.GetProposalsAsync(filter); if (!result.IsSuccess) - return Error(result.ErrorMessage); + return Error(result); var proposals = result.Value.Select(p => new { @@ -159,14 +161,14 @@ public async Task DismissProposal( // Verify the proposal belongs to the current user before dismissing var getResult = await _proposalService.GetProposalByIdAsync(proposalGuid); if (!getResult.IsSuccess) - return Error(getResult.ErrorMessage); + return Error(getResult); if (getResult.Value.RequestedByUserId != userId) return Error("Proposal not found or access denied"); var result = await _proposalService.DismissProposalsAsync(new List { proposalGuid }); if (!result.IsSuccess) - return Error(result.ErrorMessage); + return Error(result); return JsonSerializer.Serialize(new { @@ -181,4 +183,16 @@ private static string Error(string message) { return JsonSerializer.Serialize(new { error = message }, BoardResources.SerializerOptions); } + + private static string Error(Result result) + { + var message = string.Equals( + result.ErrorCode, + ErrorCodes.UnexpectedError, + StringComparison.Ordinal) + ? SensitiveDataRedactor.GenericUnexpectedFailureMessage + : result.ErrorMessage; + + return Error(message); + } } diff --git a/backend/src/Taskdeck.Application/Services/AgentRuntime.cs b/backend/src/Taskdeck.Application/Services/AgentRuntime.cs index 07f7ab1a3..da2a5a487 100644 --- a/backend/src/Taskdeck.Application/Services/AgentRuntime.cs +++ b/backend/src/Taskdeck.Application/Services/AgentRuntime.cs @@ -263,13 +263,15 @@ public async Task> RunAsync( catch (Exception ex) { _logger?.LogError(ex, "Agent run '{RunId}' failed unexpectedly", run.Id); - run.MarkFailed($"Unexpected error: {ex.Message}"); + run.MarkFailed(SensitiveDataRedactor.GenericUnexpectedFailureMessage); try { await _unitOfWork.SaveChangesAsync(CancellationToken.None); } catch (Exception saveEx) { _logger?.LogError(saveEx, "Failed to persist failure state for run '{RunId}'", run.Id); } - return Result.Failure(ErrorCodes.UnexpectedError, $"Agent run failed: {ex.Message}"); + return Result.Failure( + ErrorCodes.UnexpectedError, + SensitiveDataRedactor.GenericUnexpectedFailureMessage); } if (run.Status == AgentRunStatus.Failed) diff --git a/backend/tests/Taskdeck.Api.Tests/McpResourcesTests.cs b/backend/tests/Taskdeck.Api.Tests/McpResourcesTests.cs index 58b1e4fd4..cff4e37e0 100644 --- a/backend/tests/Taskdeck.Api.Tests/McpResourcesTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/McpResourcesTests.cs @@ -111,6 +111,62 @@ await proposalService.CreateProposalAsync(new CreateProposalDto( first.TryGetProperty("riskLevel", out _).Should().BeTrue(); } + [Fact] + public async Task ProposalResources_ListProposals_IsolatesRequesters() + { + using var scope = _serviceProvider.CreateScope(); + var userA = await CreateBoardScopedUserAsync(scope, "list-user-a"); + var userB = await CreateBoardScopedUserAsync(scope, "list-user-b"); + var (proposalA, _) = await CreateBoardScopedProposalAsync(scope, userA); + var (proposalB, _) = await CreateBoardScopedProposalAsync(scope, userB); + var proposalService = scope.ServiceProvider.GetRequiredService(); + + var resourcesA = new ProposalResources( + proposalService, + new McpBoardResourcesTests.FixedUserContextProvider(userA)); + var resourcesB = new ProposalResources( + proposalService, + new McpBoardResourcesTests.FixedUserContextProvider(userB)); + + var idsForA = ReadProposalIds(await resourcesA.ListProposals()); + var idsForB = ReadProposalIds(await resourcesB.ListProposals()); + + idsForA.Should().Equal(proposalA); + idsForB.Should().Equal(proposalB); + } + + [Fact] + public async Task ProposalResources_GetProposalDetail_IsolatesRequesters() + { + using var scope = _serviceProvider.CreateScope(); + var userA = await CreateBoardScopedUserAsync(scope, "detail-user-a"); + var userB = await CreateBoardScopedUserAsync(scope, "detail-user-b"); + var (proposalA, _) = await CreateBoardScopedProposalAsync(scope, userA); + var (proposalB, _) = await CreateBoardScopedProposalAsync(scope, userB); + var proposalService = scope.ServiceProvider.GetRequiredService(); + + var resourcesA = new ProposalResources( + proposalService, + new McpBoardResourcesTests.FixedUserContextProvider(userA)); + var resourcesB = new ProposalResources( + proposalService, + new McpBoardResourcesTests.FixedUserContextProvider(userB)); + + using var ownDocumentA = JsonDocument.Parse( + await resourcesA.GetProposalDetail(proposalA.ToString())); + using var ownDocumentB = JsonDocument.Parse( + await resourcesB.GetProposalDetail(proposalB.ToString())); + ownDocumentA.RootElement.GetProperty("id").GetGuid().Should().Be(proposalA); + ownDocumentB.RootElement.GetProperty("id").GetGuid().Should().Be(proposalB); + + var readAAsB = () => resourcesB.GetProposalDetail(proposalA.ToString()); + var readBAsA = () => resourcesA.GetProposalDetail(proposalB.ToString()); + (await readAAsB.Should().ThrowAsync()).Which.Message + .Should().Be("MCP: proposal not found or access denied"); + (await readBAsA.Should().ThrowAsync()).Which.Message + .Should().Be("MCP: proposal not found or access denied"); + } + [Fact] public async Task ProposalResources_GetProposalDetail_ReturnsOperations() { @@ -230,6 +286,16 @@ private async Task CreateBoardScopedUserAsync(IServiceScope scope, string return (created.Value.Id, board.Value.Id); } + private static Guid[] ReadProposalIds(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement + .GetProperty("proposals") + .EnumerateArray() + .Select(proposal => proposal.GetProperty("id").GetGuid()) + .ToArray(); + } + private async Task MakeTerminalWithStoredPreviewAsync(IServiceScope scope, Guid userId, Guid proposalId, string preview) { var uow = scope.ServiceProvider.GetRequiredService(); diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalResourcesErrorSafetyTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalResourcesErrorSafetyTests.cs new file mode 100644 index 000000000..7e6225f7e --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/ProposalResourcesErrorSafetyTests.cs @@ -0,0 +1,172 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Api.Mcp; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class ProposalResourcesErrorSafetyTests +{ + private const string HostileError = + "Bearer tdsk_test_secret C:\\Users\\alice\\taskdeck.db " + + "SQLite UNIQUE constraint failed: Users.Email https://provider.example/v1/internal"; + + private static readonly string[] HostileMarkers = + [ + "tdsk_test_secret", + "C:\\Users\\alice\\taskdeck.db", + "UNIQUE constraint failed", + "https://provider.example/v1/internal" + ]; + + [Fact] + public async Task ListProposals_UnexpectedFailure_ThrowsGenericErrorForCurrentUserFilter() + { + var userId = Guid.NewGuid(); + var proposalService = new Mock(MockBehavior.Strict); + proposalService + .Setup(service => service.GetProposalsAsync( + It.Is(filter => filter != null && filter.UserId == userId), + It.IsAny())) + .ReturnsAsync(Result.Failure>( + ErrorCodes.UnexpectedError, + HostileError)); + + var act = () => CreateResources(proposalService.Object, userId).ListProposals(); + + var exception = (await act.Should().ThrowAsync()).Which; + AssertGenericError(exception, "MCP: failed to list proposals: "); + proposalService.VerifyAll(); + } + + [Fact] + public async Task GetProposalDetail_UnexpectedLookupFailure_ThrowsGenericError() + { + var userId = Guid.NewGuid(); + var proposalId = Guid.NewGuid(); + var proposalService = new Mock(MockBehavior.Strict); + proposalService + .Setup(service => service.GetProposalByIdAsync( + proposalId, + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.UnexpectedError, HostileError)); + + var act = () => CreateResources(proposalService.Object, userId) + .GetProposalDetail(proposalId.ToString()); + + var exception = (await act.Should().ThrowAsync()).Which; + AssertGenericError(exception, "MCP: failed to get proposal: "); + proposalService.VerifyAll(); + } + + [Theory] + [InlineData(ProposalStatus.PendingReview)] + [InlineData(ProposalStatus.Applied)] + public async Task GetProposalDetail_UnexpectedPreviewFailure_ThrowsGenericError( + ProposalStatus status) + { + var userId = Guid.NewGuid(); + var proposalId = Guid.NewGuid(); + var proposalService = new Mock(MockBehavior.Strict); + proposalService + .Setup(service => service.GetProposalByIdAsync( + proposalId, + It.IsAny())) + .ReturnsAsync(Result.Success(CreateProposal(proposalId, userId, status))); + + if (status == ProposalStatus.Applied) + { + proposalService + .Setup(service => service.GetTerminalProposalStoredPreviewAsync( + proposalId, + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.UnexpectedError, HostileError)); + } + else + { + proposalService + .Setup(service => service.GetProposalDiffAsync( + proposalId, + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.UnexpectedError, HostileError)); + } + + var act = () => CreateResources(proposalService.Object, userId) + .GetProposalDetail(proposalId.ToString()); + + var exception = (await act.Should().ThrowAsync()).Which; + AssertGenericError(exception, "MCP: "); + proposalService.VerifyAll(); + } + + [Fact] + public async Task GetProposalDetail_KnownDomainFailure_PreservesStableMessage() + { + const string stableMessage = "Proposal not found."; + var userId = Guid.NewGuid(); + var proposalId = Guid.NewGuid(); + var proposalService = new Mock(MockBehavior.Strict); + proposalService + .Setup(service => service.GetProposalByIdAsync( + proposalId, + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.NotFound, stableMessage)); + + var act = () => CreateResources(proposalService.Object, userId) + .GetProposalDetail(proposalId.ToString()); + + var exception = (await act.Should().ThrowAsync()).Which; + exception.Message.Should().Be($"MCP: failed to get proposal: {stableMessage}"); + proposalService.VerifyAll(); + } + + private static ProposalResources CreateResources( + IAutomationProposalService proposalService, + Guid userId) + { + return new ProposalResources( + proposalService, + new McpBoardResourcesTests.FixedUserContextProvider(userId)); + } + + private static ProposalDto CreateProposal( + Guid proposalId, + Guid userId, + ProposalStatus status) + { + var now = DateTimeOffset.UtcNow; + return new ProposalDto( + proposalId, + ProposalSourceType.Chat, + null, + null, + userId, + status, + RiskLevel.Low, + "Safe proposal", + null, + null, + now, + now, + now.UtcDateTime.AddHours(1), + status == ProposalStatus.Applied ? now.UtcDateTime : null, + status == ProposalStatus.Applied ? userId : null, + status == ProposalStatus.Applied ? now.UtcDateTime : null, + null, + "mcp-resource-error-safety-test", + []); + } + + private static void AssertGenericError(Exception exception, string prefix) + { + exception.Message.Should().Be(prefix + SensitiveDataRedactor.GenericUnexpectedFailureMessage); + + foreach (var marker in HostileMarkers) + exception.Message.Should().NotContain(marker); + } +} diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalToolsErrorSafetyTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalToolsErrorSafetyTests.cs new file mode 100644 index 000000000..460b4af51 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/ProposalToolsErrorSafetyTests.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using FluentAssertions; +using Moq; +using Taskdeck.Api.Mcp; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class ProposalToolsErrorSafetyTests +{ + private const string HostileError = + "Bearer tdsk_test_secret C:\\Users\\alice\\taskdeck.db " + + "SQLite UNIQUE constraint failed: Users.Email https://provider.example/v1/internal"; + + private static readonly string[] HostileMarkers = + [ + "tdsk_test_secret", + "C:\\Users\\alice\\taskdeck.db", + "UNIQUE constraint failed", + "https://provider.example/v1/internal" + ]; + + [Fact] + public async Task GetProposalStatus_UnexpectedFailure_ReturnsGenericError() + { + var proposalId = Guid.NewGuid(); + var proposalService = new Mock(MockBehavior.Strict); + proposalService + .Setup(service => service.GetProposalByIdAsync( + proposalId, + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.UnexpectedError, HostileError)); + + var json = await CreateTools(proposalService.Object).GetProposalStatus(proposalId.ToString()); + + AssertGenericError(json); + } + + [Fact] + public async Task ListProposals_UnexpectedFailure_ReturnsGenericError() + { + var proposalService = new Mock(MockBehavior.Strict); + proposalService + .Setup(service => service.GetProposalsAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Result.Failure>( + ErrorCodes.UnexpectedError, + HostileError)); + + var json = await CreateTools(proposalService.Object).ListProposals(); + + AssertGenericError(json); + } + + [Fact] + public async Task DismissProposal_UnexpectedLookupFailure_ReturnsGenericError() + { + var proposalId = Guid.NewGuid(); + var proposalService = new Mock(MockBehavior.Strict); + proposalService + .Setup(service => service.GetProposalByIdAsync( + proposalId, + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.UnexpectedError, HostileError)); + + var json = await CreateTools(proposalService.Object).DismissProposal(proposalId.ToString()); + + AssertGenericError(json); + } + + [Fact] + public async Task DismissProposal_UnexpectedDismissFailure_ReturnsGenericError() + { + var userId = Guid.NewGuid(); + var proposalId = Guid.NewGuid(); + var proposalService = new Mock(MockBehavior.Strict); + proposalService + .Setup(service => service.GetProposalByIdAsync( + proposalId, + It.IsAny())) + .ReturnsAsync(Result.Success(CreateProposal(proposalId, userId))); + proposalService + .Setup(service => service.DismissProposalsAsync( + It.Is>(ids => ids.Count == 1 && ids[0] == proposalId), + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.UnexpectedError, HostileError)); + + var json = await CreateTools(proposalService.Object, userId).DismissProposal(proposalId.ToString()); + + AssertGenericError(json); + } + + [Fact] + public async Task GetProposalStatus_KnownDomainFailure_PreservesStableMessage() + { + const string stableMessage = "Proposal not found."; + var proposalId = Guid.NewGuid(); + var proposalService = new Mock(MockBehavior.Strict); + proposalService + .Setup(service => service.GetProposalByIdAsync( + proposalId, + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.NotFound, stableMessage)); + + var json = await CreateTools(proposalService.Object).GetProposalStatus(proposalId.ToString()); + + using var document = JsonDocument.Parse(json); + document.RootElement.GetProperty("error").GetString().Should().Be(stableMessage); + } + + private static ProposalTools CreateTools( + IAutomationProposalService proposalService, + Guid? userId = null) + { + return new ProposalTools( + proposalService, + new McpBoardResourcesTests.FixedUserContextProvider(userId ?? Guid.NewGuid())); + } + + private static ProposalDto CreateProposal(Guid proposalId, Guid userId) + { + var now = DateTimeOffset.UtcNow; + return new ProposalDto( + proposalId, + ProposalSourceType.Chat, + null, + null, + userId, + ProposalStatus.Applied, + RiskLevel.Low, + "Safe proposal", + null, + null, + now, + now, + now.UtcDateTime.AddHours(1), + now.UtcDateTime, + userId, + now.UtcDateTime, + null, + "mcp-error-safety-test", + []); + } + + private static void AssertGenericError(string json) + { + using var document = JsonDocument.Parse(json); + document.RootElement.GetProperty("error").GetString() + .Should().Be(SensitiveDataRedactor.GenericUnexpectedFailureMessage); + + foreach (var marker in HostileMarkers) + json.Should().NotContain(marker); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AgentRuntimeTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AgentRuntimeTests.cs index c42d5dcb2..6e66ce827 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/AgentRuntimeTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/AgentRuntimeTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Logging; using Moq; using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; @@ -6,6 +7,7 @@ using Taskdeck.Domain.Agents; using Taskdeck.Domain.Entities; using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; using Xunit; namespace Taskdeck.Application.Tests.Services; @@ -257,6 +259,58 @@ public async Task RunAsync_EgressViolation_MarksFailedWithViolation() result.ErrorMessage.Should().Contain("Egress violation"); } + [Fact] + public async Task RunAsync_UnexpectedException_PersistsAndReturnsGenericFailureWhileLoggingOriginalOnce() + { + var profile = CreateProfile(); + _profileRepo.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(profile); + _runRepo.Setup(r => r.GetActiveByUserIdAsync(_userId, It.IsAny())) + .ReturnsAsync(Enumerable.Empty()); + + AgentRun? capturedRun = null; + _runRepo.Setup(r => r.AddAsync(It.IsAny(), It.IsAny())) + .Callback((run, _) => capturedRun = run) + .ReturnsAsync((AgentRun run, CancellationToken _) => run); + + var logger = new CapturingLogger(); + var runtime = new AgentRuntime( + _unitOfWork.Object, + _agentPolicy, + _policyEvaluator.Object, + logger); + var hostileMessage = + "token=sk-test-secret path=C:\\Users\\private\\taskdeck.db " + + "SQLITE_CONSTRAINT_UNIQUE provider=https://provider.internal/v1"; + var exception = new InvalidOperationException(hostileMessage); + + var result = await runtime.RunAsync( + profile.Id, _userId, "will fail unexpectedly", + new[] { "inbox.triage" }, + (run, step, ct) => throw exception); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.UnexpectedError); + result.ErrorMessage.Should().Be(SensitiveDataRedactor.GenericUnexpectedFailureMessage); + + capturedRun.Should().NotBeNull(); + capturedRun!.Status.Should().Be(AgentRunStatus.Failed); + capturedRun.FailureReason.Should().Be(SensitiveDataRedactor.GenericUnexpectedFailureMessage); + capturedRun.FailureReason.Should().NotContainAny( + "sk-test-secret", + "C:\\Users\\private\\taskdeck.db", + "SQLITE_CONSTRAINT_UNIQUE", + "provider.internal"); + + var originalExceptionLogs = logger.Entries + .Where(entry => ReferenceEquals(entry.Exception, exception)) + .ToList(); + originalExceptionLogs.Should().ContainSingle(); + originalExceptionLogs[0].Level.Should().Be(LogLevel.Error); + originalExceptionLogs[0].Message.Should().Contain(capturedRun.Id.ToString()); + logger.Entries.Should().OnlyContain(entry => !entry.Message.Contains(hostileMessage, StringComparison.Ordinal)); + } + [Fact] public async Task RunAsync_RecordsEvents() { @@ -289,4 +343,34 @@ public void DefaultConstants_AreReasonable() AgentRuntime.DefaultMaxTokensPerRun.Should().BeGreaterThan(0); AgentRuntime.DefaultMaxConcurrentRunsPerUser.Should().BeGreaterThan(0); } + + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add(new LogEntry(logLevel, exception, formatter(state, exception))); + } + + public sealed record LogEntry(LogLevel Level, Exception? Exception, string Message); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + } } diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index d8a80ab77..4cdb96e3f 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -1,6 +1,6 @@ # Taskdeck Implementation Masterplan -Last Updated: 2026-09-02 +Last Updated: 2026-09-03
Planning Horizon: the revival waves in `docs/REVIVAL_PLAN.md` (truth + safety → transcript engine → open-beta launch → generalist expansion [Phase 4, ADR-0046 Accepted]) plus ADR-0051's bounded autonomous backlog lane, then a maintainer checkpoint on beta traction — _(historical: 2026-06-13→2026-07-10 this was the finite archive-pivot waves; before that an open "Next 8 to 12 weeks" release horizon)_ Companion Active Docs: @@ -522,9 +522,9 @@ Overnight substrate + correctness wave — **11 PRs merged** (per-PR gate: indep - Every issue must carry exactly one priority label (`Priority I` through `Priority V`). - Out-of-code and configuration work (containerization, deployment, security posture, observability, DR) must be tracked as first-class backlog items. -## Current Cycle Outcome (Completed) +## Cycle outcome: revival + generalist-expansion wave (2026-07-13 to 2026-07-17, completed) -Delivered in the latest cycle: +Delivered in that cycle: REVIVAL-02 — fake undo timeline removed (2026-07-17, `#1298`, Phase 1 truth + safety): the Paper review UI no longer advertises an undo window/countdown, an undo keyboard shortcut, or undo-rate copy — **no revert endpoint exists anywhere in the backend**, so the affordance was a lie. The stable side-effect `reversibility` wire shape (`summary`/`description`/`windowMs`) is preserved for GP-03 contract compatibility (assumption C6-01: a future *real* undo gets a deliberately versioned API, not this field), but the frontend maps it to factual apply-risk guidance ("Apply considerations") and the visible confidence label renders **"Operation safety"** while keeping the `/confidence` transport key unchanged (assumption C6-02: label copy only, no wire change). `PaperUndoTimeline` component + spec deleted; the countdown/undo-rate/shortcut promises removed in favour of factual elapsed age / apply considerations; executor/apply behavior untouched; truthful undo-adjacent language (destructive-action "cannot be undone" warnings, reversible archive restore, transaction rollback, parser negation handling) deliberately preserved. Proven by backend + frontend + Paper-review E2E gates. diff --git a/docs/STATUS.md b/docs/STATUS.md index 188b463d8..3574b8bcd 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -13,7 +13,7 @@ Context Fabric CF-01 — durable Capture in force (2026-08-30, `#2255`, PR `#234 - **That pass is a reconcile pass, not only a first fill** (review round 1). Its backlog is a **divergence join** — a row qualifies with no capture *or* with a capture the queue row has been written past — so an aggregate that fell behind (a `DualWriteCaptures` window, a durable write that failed and was swallowed) is repaired by appending a superseding asset. A row it cannot map is logged by id, excluded for the rest of the run so the healthy rows behind it are still reached, and left readable through its queue row; the marker completes only on an empty backlog. - **Inbox list and get resolve capture material through `ICaptureStore`**, not by parsing `LlmRequest.Payload`: the immutable source text, the `LegacySourceSnapshot` and `CapturedAtServer`. Job state (queue status, processed-at, retry count, error message) and the fields with no column yet (triage provenance, suggestion metadata, the disposition receipt's who/when/where) still come from the queue row, which is what makes the DTOs byte-identical across the switch — the queue row is now the **job** record and CF-03 replaces it. The switch has **three guards**: the backfill marker, a per-item fallback for a capture with no durable row, and a divergence check that defers to the queue row when the two disagree and the queue row wrote last. A capture can neither vanish from the Inbox nor go backwards in it, and mutation responses obey the same gate as reads. A listing reads a narrow projection, never whole aggregates with their correction history. - **Sources are immutable.** A post-intake edit (`PUT /api/capture/items/{id}/suggestion`) appends a *superseding* `SourceAsset` and leaves the original readable (`SupersedesAssetId` / `SupersededByAssetId`); the 32-asset cap counts active assets, so a capture the shipped contract lets a user edit can never start failing. Keep / archive / cancel record the durable disposition axis without erasing processing or action outcomes. `EfCaptureStore` gained the tracked read and explicit update the PR `#2280` review asked for, all owner-scoped. `CaptureIntakeSingleWriterTests` proves over the source tree that nothing but the intake constructs a `Capture` or writes the `Captures` set. GDPR export now carries each capture's durable row and its source assets (inline text included) in both the buffered and the streamed package; account erasure already removed them and is covered end to end. -- **Not delivered by this slice, and tracked:** ADR-0065 §Decision 1 also calls for `LlmRequest.Payload` to stop being mutable capture state. It has not: `UpdateSuggestionAsync` still writes the payload, and triage provenance, suggestion metadata and the disposition receipt still live in that JSON because they have no columns yet. The aggregate is now the authoritative source of the capture material; the queue row remains the job record and the home of everything CF-02/CF-03 will move. CF-01 `#2255` closed on PR `#2344`'s four verified foundation acceptance checks without claiming this residual: CF-01b `#2345` owns the JSON-twin retirement, while CF-01c `#2347` separately owns the disposition-stamp divergence and backlog-bound hardening found during scoped review. +- **Not delivered by this slice, and tracked:** ADR-0065 §Decision 1 also calls for `LlmRequest.Payload` to stop being mutable capture state. It has not: `UpdateSuggestionAsync` still writes the payload, and triage provenance, suggestion metadata and the disposition receipt still live in that JSON because they have no columns yet. The aggregate is now the authoritative source of the capture material; the queue row remains the job record and the home of everything CF-02/CF-03 will move. CF-01 `#2255` closed on PR `#2344`'s four verified foundation acceptance checks without claiming this residual: CF-01b `#2345` owns the JSON-twin retirement and remains open. CF-01c `#2347` owned the disposition-stamp divergence and backlog-bound hardening and shipped on 2026-09-03 in PR `#2417` (merge `eaa996fa2`), which reconciles capture text before stamping a disposition; `#2347` is closed. - **Evidence (this box, exact-head, after the round-1 fixes):** `dotnet build backend/Taskdeck.sln -c Release` 0 errors, 13 warnings all pre-existing in untouched test files. Domain full 1584/1584; Domain `Capture|SourceAsset` filter 113/113. Application full 4107/4107; Application `Capture|LlmQueue|AccountDeletion|DataPortability|Processing` filter 510/510. Api full 2753 passed / 4 skipped (pre-existing `LlmQuota*ConcurrencyTests` skips) / 0 failed; Api `MigrationBootstrap|Capture|Inbox|DataPortability|AccountDeletion|LlmQueue` filter 318/318; `MigrationBootstrapTests` 22/22 including `HasPendingModelChanges() == false`. Architecture 28/28 (1 pre-existing skip). Cli 147/147. `node scripts/check-docs-governance.mjs` green. `scripts/ci/validate-migrations.sh` step 1 (migrations apply to a fresh SQLite database) green with a Windows `TMPDIR`; step 2 needs the `sqlite3` CLI, which is not installed on this box — `MigrationBootstrapTests.Migrations_produce_all_expected_tables` asserts the same schema in-process and is green. - **Not verified:** no live-host run beyond the test suites — the pass has never been exercised against a large production database, so its wall-clock cost at scale is unmeasured; no frontend change and no frontend test run (the DTOs are unchanged by construction and pinned by an Api golden-path test); Postgres is untested (the backlog query and its count have SQLite raw-SQL branches, like the shipped Inbox listing, because the provider cannot translate a `DateTimeOffset` comparison, and LINQ branches for other providers that only the Testcontainer path would exercise). The divergence join compares stored `DateTimeOffset` text, which is sound only while every stamp is UTC — true throughout Taskdeck (`DateTimeOffset.UtcNow` everywhere) and the same assumption the shipped Inbox `ORDER BY CreatedAt` already relies on. @@ -28,6 +28,22 @@ Context Fabric scaffold (2026-08-30, PR `#2280`, ADR-0065 — behaviour-preservi - **What PR `#2280` puts on `main` and what it does.** A new, empty `Captures` table (migration `20260830034447_AddCaptureAggregate`, verified by `MigrationBootstrapTests`), the `Capture` aggregate and `ICaptureStore`/`EfCaptureStore`, the four capture-dimension enums with `CaptureSourceMapping` (total over the legacy enum, test-enumerated), `CaptureLifecycleState` + policy, `ProcessingCapability`, `ProcessorManifest` + `ProcessorManifestValidator` + `processor-manifest.v1.schema.json`, the worker-protocol envelopes + `WorkerProtocolValidator` (`docs/architecture/WORKER_PROTOCOL_V1.md`), and `IRepresentationStore`/`IBlobStore` as unregistered contracts. `CaptureService` gained a container-resolved constructor that mirrors every new capture into `Captures` under the queue row's own id **only when `ContextFabric:DualWriteCaptures` is true — default false**, so an unchanged install behaves exactly as before; Inbox reads stay on the queue row until CF-01 `#2255` completes the ID-preserving backfill. - **Evidence (filters, not whole projects):** Domain `Capture|CaptureSourceMapping|ProcessingCapability` filter green, Application `Processing|CaptureService` filter green (the new classes plus the existing capture-service suite), Architecture 26/26 (1 skipped, pre-existing), `MigrationBootstrapTests` 15/15 — all at the PR head; `dotnet build backend/Taskdeck.sln -c Release` 0 errors; exact-head required CI green. The fresh-context review of the PR found one HIGH (the seam overrode the mapping's producer) fixed before merge and confirmed flag-off behaviour, transaction atomicity, migration, mapping totality and layering clean. **Not verified:** the dual-write flag on a live host against a real database (`EfCaptureStore` is not exercised by a test; unit tests mock the store); the standalone MCP hosts honour the flag only through the registration added in the same PR; the frontend is untouched. +v0.3 integration wave (2026-09-03, `main` `468d76dc8` to `e1ea14fad`): +- **Capture text is reconciled before a disposition stamp lands (`#2347`, PR `#2417`, merge `eaa996fa2`).** Keep, Archive, Reactivate and Cancel now reconcile the exact CAS-written queue text into immutable durable source lineage before advancing the aggregate stamp, and disposition and cancellation retries share one optimistic queue guard (including already-cancelled rows) so a stale request cannot mutate durable state. Divergent archived captures are left honestly outstanding rather than silently reconciled, and SQLite exclusions move ahead of an exact `LIMIT batchSize` through one `json_each` parameter. This closes the masking defect that CF-01 recorded, so the strong Context Fabric configuration claims are restored. Application capture/disposition/backfill filter 121 passed, API capture/backfill/repository filter 96 passed, focused SQLite store/backfill filter 15 passed, with red-first regressions for stale Keep/Archive, archived false reconciliation, SQL exclusion and bound behaviour, stale CAS loss, and already-cancelled recovery. Two bounded fresh-context review rounds; the round-1 HIGH on already-cancelled recovery was fixed. **Not verified:** the full solution run hit the known `#2399` command-shape instrumentation flake once; the exact test then passed twice on clean `main` and once on the branch. +- **Board detail loads are bounded and cannot be clobbered by a stale refresh (`#1721` partial, PR `#2419`, merge `07534d9c6`).** The board, cards and labels detail fan-out now carries an explicit 10 second deadline and skips automatic retries, so the caller reaches a terminal state instead of leaving the core board route loading indefinitely. A new generation aborts the prior one and its pending siblings, stale results are suppressed, and board data commits only when the whole current generation succeeds; a failed refresh preserves the last loaded board, cards and labels for the later explicit Retry path. 103 focused tests passed. **Does not close `#1721`:** Paper and Legacy Retry controls and the browser recovery proof remain, and they are blocked until PR `#2397` releases `BoardView.vue` and `PaperBoardView.vue`. +- **SignalR bearer tokens no longer reach hosting request logs (`#2351` partial, PR `#2420`, merge `db679b8c1`).** A post-configuration Warning floor on `Microsoft.AspNetCore.Hosting.Diagnostics` survives provider-specific Trace overrides. The regression sends a real host-signed synthetic JWT and proves the recording provider stays active for unrelated Information logs while the token occurrence count stays zero. +- **Ops CLI unexpected failures persist and return only a generic message (`#2351` partial, PR `#2422`, merge `e1ea14fad`).** An unknown exception in API-side Ops CLI command execution no longer reaches `CommandRun.ErrorMessage`, `CommandRunLog.Message`, the returned command DTO, or `/api/logs`; the original exception is retained exactly once in protected structured logs with the existing command-run and correlation identifiers, and deliberate `DomainException` and parameter-validation messages still surface. **`#2351` stays open** for `AgentRuntime` persisted unknown failures, the MCP failed-`Result` boundary, standalone CLI unexpected errors, provider-health policy under `#2213`, and a caller-cancellation classification gap. +- **A superseded Smart CI plan is no longer a false red in shadow mode (`#2327` partial, PR `#2415`, merge `4f0f0525f`).** The shadow workflow always instantiates `Smart CI / Required Gate`, so a concurrency-superseded Plan job used to turn the stable gate context red even though the replacement run was the useful observation. A trusted `cancelled` plan is now green **only in shadow mode**, and its receipt stays explicitly unusable as enforcement or recall evidence (`wouldFail: true`, `plan-job-cancelled`, an observation-exclusion note). Enforce mode, and `failure`, `timed_out`, `skipped`, invalid and identity-mismatched plans, all remain fail-closed. The `ci-run.v1` schema admits the new diagnostic code. No workflow topology, policy, lane selection, runner, branch-protection or execution-mode change. 86 Smart CI tests passed. **`#2327` stays open:** the landed-commit verifier does not exist yet, cancellation provenance cannot yet distinguish a manual cancel from a concurrency supersede, and the gate is not a required context. +- **The misleading Review Apply-rate metric was removed rather than wired up (`#2205`, PR `#2413`, merge `b74b8d688`).** See the split-residual entry under Post-v0.2.0 Review-queue liveness below. +- **Not recorded here, and deliberately so:** this block covers behaviour that landed on `main`. Open PRs `#2408`, `#2412`, `#2416`, `#2421` and `#2427` are not shipped reality; their state lives on the PRs and their issues. + +v0.3 integration wave, second half (2026-09-03, `main` `aefed25b9` to `a127dee4f`): +- **Review revision-save focus is bound to the edit session that started the save (`#2215` partial, PR `#2423`, merge `051fb0243`).** Focus restoration after a revision save is invalidated when the reviewer selects another proposal in the meantime, including A to B to A navigation, so a stale save cannot move focus onto the current proposal's Request-edit button. Ordinary successful saves still restore focus and failed saves keep the editor. Evidence: `PaperReviewView.spec.ts` 141/141 and the 17 `useProposalRevisions` tests, typecheck and build green. Other `#2215` Part-C residuals stay open. +- **Board dialogs share one focus-management and realtime-safe draft model (`#1975`, PR `#2397`, merge `751a4c36d`).** `useDialogFocusManagement` now backs both `TdDialog` and `PaperBoardDialogShell` (capture, initial focus, bidirectional Tab trap, restore, visual-viewport). `useRealtimeSafeDialogDraft` preserves unsaved Paper and Legacy board or column fields across same-entity realtime refreshes while untouched sibling fields adopt server state. A complete `CardItem` Enter press is one activation and no longer continues into the opened dialog; dialog and card activation keys stay out of global board shortcuts; the selected Paper lane is visible with `aria-current`; every Paper column-reorder path is serialized. +- **Unexpected AgentRuntime failures persist and return only the generic message (`#2351` partial, PR `#2428`, merge `1eb71be2a`).** An unknown agent-step exception no longer leaks its text into the persisted run or the response; one protected structured log keeps the run ID. Regression coverage includes hostile token, path, constraint and provider values. Evidence: `AgentRuntimeTests` 13/13, Application 4,125/4,125; the full-solution command still reports the pre-existing `#2399` command-shape isolation failure, which this change does not touch. +- **Failed board loads are retryable without losing the last good board (`#1721` completion, PR `#2429`, merge `a127dee4f`).** Paper and Legacy show a specific accessible load error with an explicit Retry; a same-board refresh failure keeps loaded lanes and the Legacy canvas mounted; a newly routed board that fails to load hides stale content, controls, dialogs, forms and mutation shortcuts rather than showing another board under the new route identity. Duplicate, stale and post-unmount loads are guarded and realtime resumes only after a current successful fetch. The reload-based E2E claim was replaced by a same-document Paper recovery journey (exactly two board-detail GETs). +- **Two dev-only dependency bumps merged with the security triage recorded on the PRs:** `fast-uri` 3.1.5 to 3.1.7 (`#2407`, merge `aefed25b9`; four high-severity advisories, exposure build-only and test-only via nested `ajv` under `workbox-build` and Stryker, per `docs/ops/DEPENDENCY_UPDATE_POLICY.md`) and `@humanfs/node` 0.16.7 to 0.16.8 (`#2406`, merge `6837bf107`; ESLint-only, no advisory). + v0.3.0-rc.1 SHIPPED (2026-08-30, annotated tag `3fc9f6e8e` peels to `9d2ea3c7c`): - **The public v0.3.0-rc.1 pre-release exists**, cut by the agent under the maintainer's v0.3 RC deck reply q-1 A (2026-08-30; map `map:v1:bec0a8dd…dd9138`; record `#1947`) and the repository's declared authority. The GitHub Release is `prerelease=true`, `draft=false`, published 2026-08-30T02:26:06Z with three assets — `taskdeck-v0.3.0-rc.1-win-x64.zip` (53,916,746 bytes), its `.sha256` sidecar, and `taskdeck-v0.3.0-rc.1-provenance.txt` — and a composed page (download badge first, RC banner, SHA-256, quick-start link, `## Breaking changes` lifted from UPGRADING, `## Highlights` from `docs/releases/notes/v0.3.0-rc.1.md`, grouped `## What's changed`). `/releases/latest` still resolves to `v0.2.0`. - **Tag workflows:** CI Release 33287786328, Release Security 33287786318, Release Container 33287786267, Release Desktop 33287786253 — all four success. **GHCR:** `ghcr.io/chris0jeky/taskdeck:0.3.0-rc.1` published (`sha256:d47bdf2d…2db67`), `latest` and `0.2` both still `sha256:e4915d72…8c752`, and no `0.3` alias exists — the floating `latest` / `0.2` index digest is unchanged from the pre-tag capture (`sha256:e4915d72…8c752`) and no `0.3` alias was created, which is the live proof of `#2217`/PR `#2223` that the threat-model row was waiting for. @@ -35,10 +51,10 @@ v0.3.0-rc.1 SHIPPED (2026-08-30, annotated tag `3fc9f6e8e` peels to `9d2ea3c7c`) - **Not verified at the tag:** a live-provider transcript run on this exact head — the last one was on `3adde232b` (2026-08-27), and the ~275 commits since include real triage changes (`CaptureTriageService` / `LlmCaptureTriageExtractor` degradation outcomes and the visible notice, the reference-day prompt change `#2193`, source-labelled confidence provenance), so that gap is a genuine validation hole, not a formality; real screen-reader use; the non-headless double-click first run on a stock Windows profile (the harness runs headless); the RC page's grouped changelog before `.github/release.yml` was on the default branch at tag time. - **Known at the tag:** MCP scope denial is a JSON-RPC `isError` result with HTTP 200 (protocol contract — a caller checking only HTTP status reads a denial as success); a headless packaged run stores data next to the executable (`#2181`); `latestRevisionId` is `PendingReview`-only on the wire (see `utils/proposalIdentity.ts`). -v0.3 post-RC integration (through 2026-09-02): +v0.3 post-RC integration (2026-08-30 onward; entries are topical, so check the 2026-09-03 block above as well): - **Production encrypted recovery is shipped (`#2235`, `#2238`, `#2239`, PRs `#2360`/`#2361`, merges `3af00b6c3`/`73371ef7b`).** Packaged Docker images include pre-host `taskdeck-backup` and `taskdeck-restore` commands, and the CLI exposes read-only `--verify-connectors`. Backup uses SQLite's online backup API and authenticated encryption; restore validates integrity, schema, and connector decryptability, removes stale SQLite sidecars, creates a safety archive, and rolls back a failed promotion. The hosted Container Images checks succeeded on both landing PRs, with the production-image smoke proving restored rows, connector verification, and no leftover sidecars; the operator procedure and monthly/quarterly drill cadence live in `docs/ops/DISASTER_RECOVERY_RUNBOOK.md`. **Not verified:** a human production restore drill, measured RPO/RTO, or live access to every external connector provider. - **Repeated credential-login failures coalesce into one durable diagnostic receipt (`#2373`, PR `#2384`, merge `83fe24505`).** Each failed credential attempt replaces only its prior login-failure toast and retains structured diagnostics when available; a successful credential login clears only that receipt, leaving registration, password, OAuth, and unrelated receipts independent. Evidence: 47 focused frontend tests, the full 357-file / 5,318-test frontend suite, typecheck, production build, scoped lint, exact-head independent review, and a hosted rollup of 24 successes / 11 declared skips including a successful E2E Smoke. **Not verified:** a dedicated manual-browser or screen-reader pass; direct overlapping store calls resolve in completion order, while the only production credential form serializes submissions. -- **The archive-card proposal's complete HTTP lifecycle is pinned (`#2185` partial, PR `#2383`, merge `26dc1e9be`).** The API proof registers a user, creates the card and proposal, approves and executes it, then reads back the persisted blocked card, `Applied` proposal, and `Archived` audit entry. The focused proof passed 1/1, the neighbouring `AutomationProposalsApiTests` suite passed 50/50 on the landing base, and the exact-head hosted rollup was 24 successes / 11 declared skips including a successful E2E Smoke. **Not verified:** preview seeding, an already-blocked audit-summary case, production-payload fidelity, or the remaining user-facing wording; `#2185` stays open for those residuals. +- **The archive-card proposal's complete HTTP lifecycle is pinned (`#2185` partial, PR `#2383`, merge `26dc1e9be`).** The API proof registers a user, creates the card and proposal, approves and executes it, then reads back the persisted blocked card, `Applied` proposal, and `Archived` audit entry. The focused proof passed 1/1, the neighbouring `AutomationProposalsApiTests` suite passed 50/50 on the landing base, and the exact-head hosted rollup was 24 successes / 11 declared skips including a successful E2E Smoke. **Not verified at the time of that slice:** preview seeding, an already-blocked audit-summary case, production-payload fidelity, or the remaining user-facing wording. Those residuals shipped on 2026-09-03 in PR `#2410` (merge `a32b4818d`), which makes the archive-card proposal preview the real block outcome; `#2185` is closed. - **Partial-date residual coverage now pins January-to-December rollover and same-day retention (`#2193` partial, PR `#2380`, merge `36fd01d41`).** The parser-contract fixtures supply ISO `dueDateHint` values and prove year-window retention across the real prompt-output parser boundary; they do not prove model derivation. Evidence: 34 focused and 4,112 full Application tests plus an exact-head hosted rollup of 24 successes / 11 declared skips including a successful E2E Smoke. **Not verified:** that a live provider follows the derivation instruction; provider/prompt behavior and capture-day anchoring remain tracked on `#2210`. - **The EF8 runtime guard now includes the SQL Server provider (`#2386`, PR `#2387`, merge `dcd258af2`).** Dependabot's existing major-version cap now names `Microsoft.EntityFrameworkCore.SqlServer` alongside the rest of the coordinated EF runtime stack, and the operator policy names the same boundary, so an isolated EF9 provider bump cannot be proposed as routine maintenance. All 20 exact-head hosted checks passed, including Linux/Windows backend and API suites, migration validation, container images, and E2E Smoke. **Not verified:** an EF9 migration or live SQL Server upgrade; both were deliberately outside this prevention-only slice. @@ -47,7 +63,7 @@ v0.3 post-RC integration (through 2026-09-02): - **Launch-kit execution and security gates are fail-closed (`#2390`, PR `#2392`, merge `982f40e8e`).** Publication now requires a working public home plus signed-out ZIP/checksum proof, loopback-listener claims name exposure overrides, Compose setup names both required secrets, and ordinary bugs stay separate from private vulnerability reporting. Docs/operations governance, 29 DesktopRuntime tests, diff hygiene, exact-head review, and all 20 hosted checks including E2E Smoke passed. **Not verified:** a real anonymous public-home/SC-8 journey; `#2391` retains copy-current and standalone-draft residuals. - **Paper quick capture now exposes both its destination and submit action (`#1937`, PR `#2393`, merge `12ffc4e9f`).** The nib has a visible disabled-aware action, platform-correct shortcut copy, and honest active-board versus boardless destination text in English, Italian, and Spanish. Three focused component specs passed 78 tests, the locale guard passed 19, typecheck and diff hygiene passed, and all 36 hosted checks succeeded or were deliberately skipped, including E2E Smoke. **Not verified:** a dedicated manual-browser or screen-reader pass; two declined LOW polish notes remain (Spanish accenting and redundant test-local i18n setup). - **Review keyboard focus residuals are closed (`#1983`, PR `#2396`, merge `125804d34`).** Apply confirmation via Enter is guarded so only unmodified Enter confirms from container focus; Ctrl/Meta/Alt/Shift+Enter chords remain inert. Revision edit cancel and successful save predictably restore focus to the Request edit rail control, guarded by proposal identity to prevent late-save focus jumps across selection changes. Evidence: targeted component and view integration tests in `ApplyToBoardDialog.spec.ts` and `PaperReviewView.spec.ts`, typecheck, production build, scoped lint, exact-head review, and hosted CI with E2E Smoke passed. **Not verified:** dedicated screen-reader speech or native cross-browser chord quirks. -- **Authenticated API responses are out of every browser-side cache (`#2350`, PR `#2381`, merge `0974eeb8a`).** The NetworkFirst API runtime rule is removed rather than narrowed, so `vite.config.ts` now emits only StaleWhileRevalidate locale chunks and CacheFirst build-owned `/assets/` and `/icons/`; `ApiCacheControlMiddleware` stamps `no-store, private` server-side; and a forced retirement purges any pre-`#2350` worker and its orphaned runtime caches across sessions. 29 files, +1923/-69, including a new generated-worker contract test and a `pwa-api-cache` E2E journey. The retired runtime-cache contract suite is explicitly `describe.skip`, not deleted. Exact-head hosted rollup: 24 successes / 11 declared skips. **Not verified:** a real installed-PWA upgrade journey on a device that still holds a pre-`#2350` worker; `#2382` tracks keeping the generated-worker contract runnable from a fresh checkout, and the stale `NetworkFirst for API calls` comment above `runtimeCaching` in `vite.config.ts` still needs removing. +- **Authenticated API responses are out of every browser-side cache (`#2350`, PR `#2381`, merge `0974eeb8a`).** The NetworkFirst API runtime rule is removed rather than narrowed, so `vite.config.ts` now emits only StaleWhileRevalidate locale chunks and CacheFirst build-owned `/assets/` and `/icons/`; `ApiCacheControlMiddleware` stamps `no-store, private` server-side; and a forced retirement purges any pre-`#2350` worker and its orphaned runtime caches across sessions. 29 files, +1923/-69, including a new generated-worker contract test and a `pwa-api-cache` E2E journey. The retired runtime-cache contract suite is explicitly `describe.skip`, not deleted. Exact-head hosted rollup: 24 successes / 11 declared skips. **Not verified:** a real installed-PWA upgrade journey on a device that still holds a pre-`#2350` worker; `#2382` (keeping the generated-worker contract runnable from a fresh checkout) shipped on 2026-09-03 in PR `#2402` (merge `b66da8fd4`) and is closed. The stale `NetworkFirst for API calls` comment above `runtimeCaching` in `vite.config.ts:86` still needs removing. v0.3 RC preparation (2026-08-30): - **MCP stdio known-tool calls now return actionable identity remediation without weakening fail-closed access (`#2228` code, PR `#2316`, merge `5e0d5eba2`).** When multiple active users have no `McpServer:DefaultUserId`, or the configured ID does not name an active user, every identity-gated stdio request still denies access and the provider writes the existing non-secret configuration guidance once at Warning to stderr across the host process; a known `tools/call` also returns that guidance in an `isError: true` tool result. `initialize` remains lazy, but tool/resource discovery and resource reads resolve identity and return generic access-denied JSON-RPC errors. HTTP MCP and every other identity failure retain generic denial. Evidence: 195 focused MCP tests, including production child-process coverage of two denied calls with one stderr warning; Architecture Tests 26 passed / 1 declared skip; exact-current-base patch-identity and adversarial review; automatic Codex review; and hosted Required plus Extended CI green with 21 successes / 11 declared skips including E2E Smoke. **Not verified:** a simultaneous first-failure race or explicit cancellation test; the synchronization and exception-flow paths were inspected. The configuration and upgrade notes in this reconciliation complete the operator-facing half before `#2228` closes. @@ -93,7 +109,7 @@ Post-v0.2.0 Review-queue liveness (2026-08-29): - **Background Review queue requests are deadline-bound and degrade politely (`#2214`, PR `#2388`, merge `4b8075632`).** Each background queue request is bound to an **8-second deadline**; three consecutive transient failures preserve the last trustworthy queue and expose a polite degraded notice rather than blanking the list, and any successful poll or explicit load clears it. `403` authority, teardown and supersession semantics are unchanged, and both Legacy and Paper render the state without hiding retained actions or disturbing the Paper three-column layout. Evidence: 293 focused tests across three spec files, 180 fix-diff affected specs, typecheck, build, scoped ESLint, docs and GitHub-ops governance, diff hygiene. Initial fresh-context review found two HIGH layout/conditional defects; one bounded fix commit resolved both and the fix-diff review found no blocker. **Known MEDIUM, deferred:** the Paper stale copy is hardcoded English, carried on `#2214` item 4. **Recorded 2026-09-03 by the coordinator, not by PR `#2388`:** that PR's body claimed it had updated this file, and it did not - merge `4b8075632` changed nothing in `docs/STATUS.md`. **Not verified:** full frontend suite, Playwright or manual browser, live degraded backend, screen reader, translation quality. - **Legacy batch triage polls to a terminal detail (`#2230`, PR `#2299`, merge `4c6ce5f83`).** `pollBatchTriageCompletion` will not accept cached terminal state as completion before its first authoritative read: `observedPostEnqueueList` starts false, the completion predicate's summary branch returns false while it is unset, and it is set true only after a successful `captureApi.listItems` inside a tick. That was the defect the PR parked on twice. Merged with full exact-head CI green including `E2E Smoke`, both Windows legs and all three branch-protection contexts, after one fresh-context adversarial pass returned no blocker. **`#2230` is deliberately NOT closed.** The poll's own 60-second wall (`BATCH_TRIAGE_POLL_MAX_DURATION_MS`) is shorter than the server's worst case: the worker drains at most `MaxBatchSize` items total per tick and then sleeps `QueuePollIntervalSeconds` (shipped: 5 and 5, shared between capture and non-capture work), while the server accepts batches up to 50, so a 50-item batch needs at least 50 seconds of pure queue delay before any LLM latency. Expiry is silent - no toast, no error state, no exposed ref - so a large batch can expire back into the cached `Triaging` detail that `#2230` exists to remove. The single-item poll allows 15 minutes for strictly less work. Remaining acceptance is on the issue. Two Codex P2s about `403` handling were declined as unreachable: `GET /capture/items` is user-scoped with no board authorization and `GET /capture/items/{id}` returns `403` only on a user mismatch, so board-membership revocation produces no `403` on either read. **Not verified:** the timing above is arithmetic over committed configuration, not an observed batch run. - **The active proposal no longer changes under a live reviewer (`#2215` parts A and B, the `#2208` residual).** A poll that dropped or reordered away the row on screen used to slide the selection onto the next pending proposal: `ReviewMain` is keyed on the proposal id, so it was re-created and focus left the decision controls, while the window-level review keymap stayed enabled for the newly selected record - the next Enter/Backspace/D/E decided a proposal the reviewer never chose. `useReviewProposals` now signals a landed poll (`startQueueRefresh`'s `onQueueReplaced` hook, fired synchronously after the queue is replaced and cleared by `stopQueueRefresh`); Paper arms a one-shot flag from it, so an active-proposal change observed while armed is by definition not one the reviewer made. It pins the taken proposal, renders an explicit "This proposal left the review queue" notice in place of the decision column (localized en/it/es, `role="status"` + `aria-live="polite"`), and moves focus to the notice's own control. What silences the keymap is `activeProposal` being null in that state - `useReviewKeymap`'s `enabled` predicate requires a non-null active proposal - while the focus move is what stops focus falling to the document body and gives the reviewer a safe target; the two are independent. The copy states only what was observed - the row left the queue - because one poll answer is not proof of a decision: the list endpoint also omits a PendingReview proposal whose `deferredUntil` is in the future, and it is capped at 200 rows. The notice's action re-reads the queue authoritatively rather than only dismissing itself, and no fetch-by-id was added. A deep-linked target is unaffected: `refreshProposals` already pins it. The notice is dropped by any explicit choice - selecting a row, changing filter, changing board scope, following a deep link, or its own "Back to the queue" button, which also clears the stale explicit selection so the ordinary first-pending default resumes. **Part B:** the rendered diff and the revision state are keyed on `latestRevisionId` as well as the proposal id, in **both** skins. Keying on the id alone left a diff on screen that had been computed for the previous revision once a poll brought in one another session had saved, while Approve pins and Apply executes the server's latest; and `useProposalRevisions` kept `revisionCount` and the cached `latestRevision` from page entry, so `editablePayload` offered superseded operations to the next save. The revision resync is deliberately a read, not the full reset the proposal-change path does - closing an open composer over somebody else's change would discard a half-written edit. **Evidence:** 933 tests across all 46 composable and i18n spec files plus 502 across the 21 review/Legacy-Review/i18n/source-guard files, `npm run typecheck` and `npm run build`, all green. Seventeen mutation checks, each killing its target specs and only those: removing the selection guard (4), disarming the poll signal (4), removing the focus move (1), disabling and then forcing the Paper diff's revision comparison (1 each), dropping `latestRevisionId` from the revision-state key (1), disabling and then widening the resync branch (1 and 4), disabling then forcing the Legacy pane's revision comparison (1 and 3), and - from review round 1 - republishing a failed resync as authoritative (1), making that resync loud (1), deleting the no-op key guard (1), and dropping the notice's queue re-read (1). Round 2 adds four more: dropping the null-transition rule (1), dropping the `approvedRevisionId` fallback (1), dropping both (4), and the three round-2 specs staying green against the restored rule. Round 1 also fixed a real defect the first pass shipped: the resync left `revisionsLoaded` true, so ONE transient GET failure under a poll would have published a zero count as authoritative - a false zero-op Apply toast and a fallback to pre-revision operations - and it toasted from a background poll, against `refreshProposals`' silent doctrine. It now clears `revisionsLoaded` first and loads `{ silent: true }`. **Round 2 caught the sharper one, verified against the backend:** `latestRevisionId` is a PendingReview-ONLY value on the wire - `AutomationProposalService.BuildEffectiveProposalDto` sets it to `Status == PendingReview ? effectiveRevision?.Id : null` - so approving a revised proposal moves it `rev-X` -> `null` in the very next read, and all three keys read the reviewer's OWN approval as a collaborator revision change. In Paper that wiped the open diff mid-decision, and because the key watcher runs before the read-only conversion watcher it wiped the pane rather than letting it convert to the decision-time stored presentation. All three now key on an EFFECTIVE identity - `latestRevisionId ?? approvedRevisionId`, shared as `proposalRevisionIdentity` in `utils/proposalIdentity.ts` - and clear only on a genuine move (`proposalRevisionMoved`): an identity reaching null is a status transition, never a revision change, because revisions are append-only and of the exits only `Approve` pins a replacement while `Reject`/`Expire`/`Dismiss` pin nothing. Both halves of the rule are separately load-bearing: dropping the null rule reddens the rejected-proposal conversion spec, and dropping the `approvedRevisionId` fallback reddens the stale-pre-approval-read spec. **Not verified:** no live browser or packaged run; no two-session concurrency repro. **Out of scope, still open on `#2215`:** part C beyond the revision-state resync delivered here, and the deep-review selectors (provenance, side effects, conflicts, history), which still key on the proposal id alone. -- **Split residual:** the queue rail's **Apply rate** is a hardcoded empty, not a staleness bug - `PaperReviewView` never binds `ReviewQueueRail`'s optional `applyRate` prop, so `applyRatePct` is always `null` and "No decisions yet" renders unconditionally however many decisions were made. No refresh can fix it. Tracked as [`#2205`](https://github.com/Chris0Jeky/Taskdeck/issues/2205); `#2194` stays open until it is dispositioned. +- **Split residual, resolved by deletion (`#2205`, PR `#2413`, merge `b74b8d688`).** The queue rail's **Apply rate** was a hardcoded empty rather than a staleness bug: `PaperReviewView` never bound `ReviewQueueRail`'s optional `applyRate` prop, so `applyRatePct` was always `null` and "No decisions yet" rendered however many decisions had been made. No refresh could fix it, so the metric was removed from `ReviewQueueRail.vue` and the three locale catalogs instead of being wired up. `#2205` and `#2194` are both closed. The unrelated `applyRate` on `proposalDeepReviewApi` (similar past decisions) is a different field and still exists. v0.2.0 release preparation (2026-08-29, candidate `main` `927236bd0`): - **The two closing slices for the last v0.2 milestone issues are merged.** PR `#2145` (merge `cd878e262`) closed `#1938`: a failed capture now carries a real diagnostics payload (HTTP status, method+endpoint, Taskdeck `errorCode`, `X-Request-Id`) in both the error toast and the inline receipt, with aria wiring and per-variant receipts; follow-ups tracked on `#2147`, `#2142`, `#2146`. PR `#2144` (merge `e5c276e59`) closed `#1305`: the never-mapped RFAI-02 intent-envelope vocabulary (8 types, 2 interfaces, the `proposal-batch.v1` schema, 158 dead tests) is deleted, architecture invariant INV-12 is rewired onto the shipped `ProvenanceEvidenceLink` chain with five integrity guards, and the 200,000-character transcript cap plus export/deletion retention are documented. PR `#2153` (merge `927236bd0`) added the Windows-first release-trust programme (`docs/ops/RELEASE_TRUST_AND_DISTRIBUTION.md`, backlog `#2148`–`#2152`, human items RT-1–RT-3/CL-1/BEN-1/DIST-1) with no behavior change. Milestone `v0.2 — Coherent Context-to-Action Loop` holds **0 open / 15 closed**; every closure traces to a merge on `main` (none to `integration/v0.3.0`). @@ -255,7 +271,7 @@ Board-targeted triage now requires write access (2026-08-19, `#1794`, coordinato Transcript evidence deep link in Paper Review (2026-08-19, `#1305` AC4): - **A proposal's evidence quote now opens the transcript it came from.** `GET /api/transcripts/{id}` returns one transcript owned by the authenticated caller — the canonical LF-normalized text plus its line-indexed speaker/timestamp segments. Identity is claims-only and ownership is part of the repository lookup predicate, so another user's transcript returns `404` with a response body identical to a nonexistent one; the endpoint cannot be used as a cross-user existence oracle. The text is returned whole (the domain's existing 200,000-character cap bounds it); there is no paging. This is a read surface: nothing on it mutates a transcript, a proposal, or a board. - **The Paper provenance drawer consumes the evidence links it has been served since the typed-evidence slice.** `ProvenanceRowDto.EvidenceLinks` was already on the wire but no frontend type declared it and `ReviewMain` never passed it down (`#1284`); the selectors now flatten each row's links and the drawer renders them with a **View in transcript** affordance whenever a link is transcript evidence *and* carries a resolved span. The viewer fetches through `api/http.ts` and highlights the span in place, scrolled to centre. Spans are .NET `char` offsets and JavaScript string indices are the same UTF-16 code units, so multi-byte text highlights identically on both sides; a span landing on the trailing half of a surrogate pair widens to the whole code point. A link with null offsets — which is exactly how the backend records an ambiguous or unresolvable quote — renders as plain metadata with no affordance, so an unresolved span can never present itself as an exact quotation. Legacy Review is untouched. -- **Still open on `#1305`:** the `LlmRequest.Payload` retention boundary, provenance export, removal of the unused intent-envelope graph, and the human dogfooding follow-through. +- **Left open on `#1305` at the time of this block:** the `LlmRequest.Payload` retention boundary, provenance export, removal of the unused intent-envelope graph, and the human dogfooding follow-through. `#1305` was closed on 2026-08-27 by PR `#2144` (merge `e5c276e59`); the payload retention boundary is now carried by CF-01b `#2345`. CI estate right-sizing verdict (2026-08-19, ARCHIVE-07 / `#1275`, **ADR-0052**): - **The scheduled CI estate is right-sized to a keep/fix/kill/gate verdict per lane; every remaining workflow is green or its schedule is removed with a dated comment.** `ci-required.yml` (required gate) and `release-desktop.yml` are unchanged. `ci-nightly.yml` is green — both k6 jobs (Load and Concurrency Harness, Performance Regression Gate) passed on the last 8 consecutive nightlies (2026-08-11..08-18); the summary-export permission bug was fixed by `#1358`/PR `#1359` (`--user $(id -u):$(id -g)` plus a fail-closed `require-k6-summary.mjs` gate) and the tail thresholds were recalibrated by `#1449`/`#1445`. The board-write p95 signal is **not a code regression**: it is the documented single-writer SQLite write-convoy capacity ceiling (median ~12 ms, heavy-tailed p95 ≈ 2.0-3.0 s at 20 VUs), gated at a 4500 ms tail (1.5×) with the always-on aggregate `p(95)<2000` as a near-capacity warning. @@ -306,7 +322,7 @@ Initial log sanitization delivery (2026-08-12, PR `#1650`, merge `c03e3347`): OpenAI-compatible provider replacement shipped (2026-08-10, PR `#1537`; `#1306` closed 2026-08-30 on this shipped half — the streaming remainder is `#2241`): - **A separately configured `OpenAICompatible` provider now supports public OpenAI Chat Completions endpoints without weakening the safe `Mock` default.** Selection validates the explicit base URL, model, API key, timeouts, response/SSE byte ceilings, and optional non-secret gateway headers. Buffered completion, structured instruction extraction, board context, real upstream SSE, and explicit buffered-stream degradation share sanitized error behavior and preserve token/provider/model provenance. - **The registered client extends the direct-only telemetry boundary and adds fixed-origin egress enforcement.** The effective chain is circuit policy → protected telemetry → `EgressEnvelopeHandler` → dispatch tracking → direct sockets; it disables proxies and redirects, validates DNS at connect time, preserves the configured origin for transport, and records whether a request crossed the transport boundary so quota reservations are released only for proven pre-dispatch failures. Compatible-provider Polly state and the companion provider circuit state are tracked independently with generation-safe half-open leases. Exact `localhost` plain HTTP is limited to `Development`, `Test`, or `Testing` with the explicit live-provider gate; numeric loopback and other private/link-local origins remain blocked. -- **The guarded implementation is on `main`; real-provider validation and true streaming remain open on `#2241` (re-filed from `#1306`, closed 2026-08-30).** PR `#1537` merged as `0b0c9c70` after exact-head hosted CI completed with 26 successes / 11 intentional skips / 0 failures. Local exact-head evidence passed 157/157 focused Application tests, 81/81 focused API tests, 22/22 Architecture tests with one intentional skip, and Actionlint across all 32 workflows; the earlier provider tree also passed the full serialized backend with 7,660 tests, five intentional skips, and no failures. Issue `#1306` remains open until a maintainer supplies a compatible-provider key and verifies a visibly incremental stream in the real UI. Six nonblocking MEDIUM compatibility/readiness follow-ups are recorded on `#1306`; the buffered-refusal consumer defect is separately tracked as MEDIUM `#1617`. LOW `#1618` tracks the stale status sentence corrected in this docs-only slice. +- **The guarded implementation is on `main`; real-provider validation and true streaming remain open on `#2241` (re-filed from `#1306`, closed 2026-08-30).** PR `#1537` merged as `0b0c9c70` after exact-head hosted CI completed with 26 successes / 11 intentional skips / 0 failures. Local exact-head evidence passed 157/157 focused Application tests, 81/81 focused API tests, 22/22 Architecture tests with one intentional skip, and Actionlint across all 32 workflows; the earlier provider tree also passed the full serialized backend with 7,660 tests, five intentional skips, and no failures. At the time of this block, `#1306` was held open until a maintainer supplied a compatible-provider key and verified a visibly incremental stream in the real UI. It closed on 2026-08-30 on its shipped `OpenAICompatible` half, and that remaining verification moved to `#2241`, which is still open. The six nonblocking MEDIUM compatibility/readiness follow-ups were recorded on `#1306`; the buffered-refusal consumer defect was tracked as MEDIUM `#1617`, now closed. LOW `#1618` tracks the stale status sentence corrected in this docs-only slice. Transcript map-reduce delivery (2026-08-02, REVIVAL-08 M2 / `#1304`): @@ -316,10 +332,10 @@ Transcript triage visibility delivery (2026-08-02, `#1574`): - **The Inbox keeps its existing two-second triage poll active for up to 450 scheduled attempts (roughly 15 minutes at the normal cadence) rather than stopping after 30 seconds.** That keeps default hosted-provider map-reduce work visible while retaining terminal/error/cancellation handling, one active poll, manual refresh, and the proposal-first review boundary. Provider-aware elapsed deadlines for slower supported configurations are deliberately tracked separately in `#1585`. Transcript schema-v2 contract delivery (2026-08-02, REVIVAL-08 M3 contract slice / `#1304`): -- **LLM transcript triage now uses a strict, server-stamped `llm-triage.v2` contract while the deterministic fallback remains v1.** Model output must contain an exact task property set: title, lowercase type (`action`/`decision`/`question`), nullable bounded assignee and calendar-date hints, bounded confidence, and a bounded nonblank evidence quote. Unknown, duplicate, missing, wrongly typed, noncanonical, or over-limit output falls back for the whole capture/map leg rather than being normalized, truncated, or partially retained. Each quote must be an ordinal substring of the exact provider chunk; the existing proposed-card description preserves it for review. At this 2026-08-02 slice, classification, assignee, due-date, and confidence metadata were transient and excluded from executable proposal parameters. Update 2026-08-23 (`b6c8699f`): `dueDateHint` now materializes into the reviewed create-card proposal parameters, while type and assignee remain transient. Update 2026-08-28 (`#1307` AC1 / PR `#2160`): confidence now crosses a trusted application-only seam into source-labelled provenance and Paper Review; it remains presentation-only and excluded from executable parameters. The original slice added no migration, API/UI evidence surface, identity resolution, or automatic board mutation; `#1305` remains open for its separate durable-linkage residuals. +- **LLM transcript triage now uses a strict, server-stamped `llm-triage.v2` contract while the deterministic fallback remains v1.** Model output must contain an exact task property set: title, lowercase type (`action`/`decision`/`question`), nullable bounded assignee and calendar-date hints, bounded confidence, and a bounded nonblank evidence quote. Unknown, duplicate, missing, wrongly typed, noncanonical, or over-limit output falls back for the whole capture/map leg rather than being normalized, truncated, or partially retained. Each quote must be an ordinal substring of the exact provider chunk; the existing proposed-card description preserves it for review. At this 2026-08-02 slice, classification, assignee, due-date, and confidence metadata were transient and excluded from executable proposal parameters. Update 2026-08-23 (`b6c8699f`): `dueDateHint` now materializes into the reviewed create-card proposal parameters, while type and assignee remain transient. Update 2026-08-28 (`#1307` AC1 / PR `#2160`): confidence now crosses a trusted application-only seam into source-labelled provenance and Paper Review; it remains presentation-only and excluded from executable parameters. The original slice added no migration, API/UI evidence surface, identity resolution, or automatic board mutation; `#1305` was still open for its separate durable-linkage residuals at the time of this block, and closed on 2026-08-27 via PR `#2144`. Post-merge reconciliation (2026-08-01, PRs `#1556`, `#1558`, and `#1559`): -- **REVIVAL-09 has a durable transcript persistence foundation and live capture linkage, but `#1305` remains open.** A user-owned `Transcript` stores normalized LF text and optional line-indexed speaker/timestamp segments. It may reference a board, originating capture, and `SourceArtefact`; deleting the board or artefact nulls the corresponding FK without deleting the transcript. PR `#1556` (`bfd4e95a`, merge `3182c3ac`) shipped user-scoped deterministic reads, buffered/streaming export, account deletion, and reversible bootstrap-tested persistence. The first linkage slice now creates and reuses the canonical Transcript during triage while retaining `LlmRequest.Payload` as a compatibility duplicate. Evidence spans, the additive provenance API, and the Paper deep-link UI have since shipped; remaining `#1305` scope is the final payload-retention boundary, provenance export, and removal of the unused intent-envelope graph. +- **REVIVAL-09 has a durable transcript persistence foundation and live capture linkage.** (`#1305` was still open when this block was written; it closed on 2026-08-27 via PR `#2144`.) A user-owned `Transcript` stores normalized LF text and optional line-indexed speaker/timestamp segments. It may reference a board, originating capture, and `SourceArtefact`; deleting the board or artefact nulls the corresponding FK without deleting the transcript. PR `#1556` (`bfd4e95a`, merge `3182c3ac`) shipped user-scoped deterministic reads, buffered/streaming export, account deletion, and reversible bootstrap-tested persistence. The first linkage slice now creates and reuses the canonical Transcript during triage while retaining `LlmRequest.Payload` as a compatibility duplicate. Evidence spans, the additive provenance API, and the Paper deep-link UI have since shipped. The trailing `#1305` list that used to sit here is historical: PR `#2144` (merge `e5c276e59`) deleted the unused intent-envelope graph and documented the retention boundary, and the `LlmRequest.Payload` retention boundary itself is carried by CF-01b `#2345`. - **MCP `create_card` now persists one usable column contract through proposal review and Apply.** When `column_id` is omitted, the write tool resolves the board's deterministic first column before saving the proposal; inaccessible boards, wrong-board columns, and columnless boards fail before an unusable proposal can be created. Preview, approve, and execute therefore consume the same canonical `columnId`, while the MCP path remains proposal-first and never mutates the board directly. PR `#1558` (`a5ebc160`, merge `cbf12e5d`) closed `#1354`; the current-main `McpToolsTests` run passed 33/33. - **Hosted PostgreSQL evidence now fails closed on skipped container cases.** PR `#1559` (`5f721cae`, merge `6a92516c`) closed `#1520`: the reusable lane sets `TASKDECK_REQUIRE_DOCKER=true`, proves a forced-unavailable negative control fails for the expected reason, and validates the positive TRX by fully qualified PostgreSQL test identity rather than accepting same-name host-native tests. The hosted exact-head lane passed; current-main local proof passed the verifier's 5 tests and the expected Dockerless 7 passed / 28 skipped contract. Dockerless green remains graceful-gate evidence only, never PostgreSQL parity proof. - **Agent merge authority is declaration-driven, not owner-click-only by default.** Read `.agent-harness/tier.json` live rather than copying its values into operational docs; merge disposition comes from the canonical global pipeline plus explicit task scope. The stale blanket prohibition in the Codex autonomy runbook is retired. Taskdeck's repository-specific exact-head Required CI evidence and separately scoped human decisions/external mutations remain intact. @@ -620,7 +636,7 @@ Direction guardrails (explicit): - Cross-cutting UI infrastructure: - command palette with global search (Ctrl+K): live cross-board search for boards and cards via `/api/search`, with 200ms debounced queries, abort-on-supersede, and keyboard-first grouped results navigation - feature flags, correlation IDs, toasts, keyboard shortcuts - - shared UI primitives foundation (UI-02): 15 TdButton/TdInput/TdDialog/TdDropdown/TdTooltip/TdBadge/etc. primitives built on Reka UI via shadcn-vue ownership model with WAI-ARIA keyboard foundation; stack decision documented in `docs/analysis/ui-primitive-stack-decision-spike.md` + - shared UI primitives foundation (UI-02): 15 TdButton/TdInput/TdDialog/TdDropdown/TdTooltip/TdBadge/etc. primitives at UI-02 delivery, 18 today built on Reka UI via shadcn-vue ownership model with WAI-ARIA keyboard foundation; stack decision documented in `docs/analysis/ui-primitive-stack-decision-spike.md` - appshell premium reskin: shell sidebar, topbar, command palette, and keyboard help components now use `--td-*` design token system with focus-visible accessibility rings and glass morphism effects - board/card surface polish: board canvas, toolbar, action rail, column lanes, and card components now use design-token-based styling with standardized interactive states and accessibility focus rings - centralized JWT token storage abstraction (`utils/tokenStorage.ts`) with base64url + JSON payload validation, `isValidJwtStructure` guard, and `clearAll` helper; session-token storage ADR at `docs/analysis/session-token-storage-adr.md` @@ -630,10 +646,10 @@ Direction guardrails (explicit): - WCAG 2.1 AA accessibility baseline: skip-to-content link, `sr-only` utility, `eslint-plugin-vuejs-accessibility` rules, ARIA landmarks and roles across HomeView/TodayView/ReviewView/InboxView/CaptureModal/ToastContainer/BoardView, and Paper-default Playwright axe-core E2E regression for Home, Today, Inbox, Review, a populated Board, and Login - PWA/offline client readiness (`#95`): `vite-plugin-pwa` configured with Workbox `generateSW`, a precached app shell, **network-only API responses** (`#2350`: no service-worker or browser cache for identity-bound responses, enforced by `ApiCacheControlMiddleware` and a forced retirement of the pre-`#2350` worker), CacheFirst static assets restricted to the build-owned `/assets/` and `/icons/` directories, StaleWhileRevalidate lazy locale chunks, and SPA navigation fallback for offline deep links; `useOnlineStatus` provides reactive connectivity tracking, `OfflineBanner` an ARIA live region, and `SwUpdatePrompt` user-controlled updates. The generated manifest retains separate `any`/`maskable` icon purposes, while packaged delivery still needs the `manifest-src` CSP repair tracked by `#2045`; offline behavior is documented in `docs/platform/PWA_OFFLINE_BEHAVIOR.md`. - Large view decompositions (hotspot refactor wave): - - `ActivityView.vue` decomposed from ~735 → ~117 lines via `useActivityQuery` composable + `ActivitySelector` + `ActivityResults` components - - `BoardView.vue` decomposed from ~771 → ~270 lines via `useBoardDragDrop` + `useBoardKeyboardNav` composables + `BoardToolbar` + `BoardActionRail` + `BoardCanvas` + `BoardDialogHost` components - - `ReviewView.vue` decomposed from ~1,659 lines to ~148-line shell + 6 components (`ReviewHeader`, `ReviewSummaryCards`, `ReviewEmptyState`, `ReviewProposalCard`, `ReviewProposalActions`, `ReviewProposalDetails`) + 2 composables (`useReviewProposals`, `useReviewActions`) - - `InboxView.vue` decomposed from ~1,527 lines to ~222-line shell + `InboxListPanel`, `InboxDetailPanel`, `useInboxOrchestrator` composable, and `inboxUtils` + - `ActivityView.vue` decomposed from ~735 → ~117 lines (161 today) via `useActivityQuery` composable + `ActivitySelector` + `ActivityResults` components + - `BoardView.vue` decomposed from ~771 → ~270 lines (699 today; it has regrown past the recorded decomposition) via `useBoardDragDrop` + `useBoardKeyboardNav` composables + `BoardToolbar` + `BoardActionRail` + `BoardCanvas` + `BoardDialogHost` components + - `ReviewView.vue` decomposed from ~1,659 lines to a ~148-line shell, and is 12 lines today: it is now a Paper/Legacy theme switch whose implementation lives in `views/paper/PaperReviewView.vue` and `views/LegacyReviewView.vue`. It was then a shell + 6 components (`ReviewHeader`, `ReviewSummaryCards`, `ReviewEmptyState`, `ReviewProposalCard`, `ReviewProposalActions`, `ReviewProposalDetails`) + 2 composables (`useReviewProposals`, `useReviewActions`) + - `InboxView.vue` decomposed from ~1,527 lines to a ~222-line shell, and is 12 lines today for the same reason (`views/paper/PaperInboxView.vue` and `views/LegacyInboxView.vue` hold the implementation). It was then a shell + `InboxListPanel`, `InboxDetailPanel`, `useInboxOrchestrator` composable, and `inboxUtils` - `AutomationChatView.vue` decomposed from ~1,523 lines to ~235-line shell + 7 components (`ChatHeroHeader`, `LlmHealthStatusBar`, `ChatSessionSidebar`, `ChatMessageList`, `ChatParseHintCard`, `ChatToolCallDetails`, `ChatComposeBar`) + `useAutomationChat` composable - `CardModal.vue` decomposed from ~681 lines to ~190-line shell + 6 components (`CardModalHeader`, `CardModalForm`, `CardModalLabels`, `CardModalComments`, `CardModalMetadata`, `CardModalActions`) + `useCardModal` composable (242 lines) - `StarterPackCatalogModal.vue` decomposed from ~1,253 lines to ~234-line shell + 5 components (`StarterPackCatalogList`, `StarterPackCatalogDetail`, `StarterPackImportInput`, `StarterPackImportDetail`, `StarterPackResultPanel`) + 3 composables (`useStarterPackCatalog`, `useStarterPackImport`, `useStarterPackResult`) + shared CSS tokens @@ -679,7 +695,7 @@ order is the revival phases in `docs/REVIVAL_PLAN.md`, and identity/direction is ## Test Status (Executed) -> **Authoritative test totals live in `docs/TESTING_GUIDE.md`** ("Current Verified Totals"). The per-section figures in this block are a historical snapshot (last recertified 2026-04-25) that has since drifted out of sync with TESTING_GUIDE; treat TESTING_GUIDE as the single source of truth and recertify there from a green CI/nightly run. (Tracked for cleanup in #1138.) +> **No repository-wide aggregate test total in this repo is currently trustworthy.** This block's per-section figures are a 2026-04-25 snapshot. `docs/TESTING_GUIDE.md` ("Current Verified Totals") is newer, at 2026-05-16, and is the better of the two, but it is itself now over three months stale and is *below* this file's own later exact-head measurements: it records Application 3,185 where the 2026-08-30 exact-head run recorded 4,107. A third figure at the end of this section ("7,865+ passing") is inconsistent with both. Until TESTING_GUIDE is recertified from a green CI/nightly run, **quote the exact-head, per-slice counts recorded in the dated delivery blocks above rather than any repository-wide total.** (Tracked for cleanup in #1138.) Verification Date: 2026-04-25 (recertified after PRs #960–#969 audit-remediation wave) @@ -792,7 +808,24 @@ Required workflow: `.github/workflows/ci-required.yml` - `frontend-unit` (Ubuntu/Windows) - lint + typecheck + build + unit tests - `container-images` (Ubuntu) -- `e2e-smoke` (Ubuntu, depends on prior jobs) +- `e2e-smoke` (Ubuntu, depends on `docs-governance`, `backend-architecture`, `backend-unit`, + `api-integration`, `migration-validation` and `frontend-unit`; if any one of those is killed or + times out, `e2e-smoke` reports `skipped` and the gate produces no verdict rather than a red one) +- `release-workflow-contract` (Ubuntu) +- `migration-validation` (Ubuntu) +- `paper-color-audit` (Ubuntu) +- `secret-scan` (Gitleaks) **- branch-protection required** +- `dependency-security` (Dependency Security Signals) **- branch-protection required** +- `sast-scan` (Semgrep) **- branch-protection required** + +> **What branch protection actually enforces.** Measured live on 2026-09-03: +> `required_status_checks.contexts` on `main` is exactly `["Dependency Security / Dependency Security Signals", +> "SAST Scan / SAST Scan (Semgrep)", "Secret Scan / Gitleaks Scan"]`, with `strict: false`, +> `required_approving_review_count: 0`, `required_conversation_resolution: false` and +> `enforce_admins: false`. Every other `ci-required.yml` job above is doctrinally required and +> agent-enforced, not machine-enforced: GitHub will report `mergeStateStatus: CLEAN` for a PR whose +> backend, frontend or E2E job is red. Read `gh pr checks` before any merge; never infer merge +> eligibility from `CLEAN`. Extended/non-blocking workflow: `.github/workflows/ci-extended.yml` diff --git a/docs/security/SECURITY_LOGGING_REDACTION.md b/docs/security/SECURITY_LOGGING_REDACTION.md index a4448f642..bd0e198f1 100644 --- a/docs/security/SECURITY_LOGGING_REDACTION.md +++ b/docs/security/SECURITY_LOGGING_REDACTION.md @@ -32,6 +32,13 @@ It applies to API middleware, SignalR transport request logging, queue/worker lo - API-side Ops CLI command runs persist and return only the stable generic failure message for unknown exceptions. The original exception is logged once with the existing command-run and correlation IDs; deliberate domain failures keep their stable message. +- Agent runtime runs persist and return only the same stable generic failure message for unknown + step exceptions. The original exception is logged once with the run ID and ambient correlation + context; deliberate cancellation, timeout, egress, validation, and quota messages stay specific. +- MCP proposal tools and proposal resources replace application results classified as + `UnexpectedError` with the same stable generic failure message before returning tool output or + throwing a resource error. Known domain result messages stay specific, and diagnostic logging + remains owned by the service and MCP operation boundaries. - Capture-source validation errors use generic wording (`Invalid capture source value`) instead of reflecting the untrusted source string. - Opt-in Sentry keeps server-side exception tracking and the existing event/breadcrumb scrubbing, but does not decorate the registered OpenAI, OpenAICompatible, Ollama, or outbound-webhook clients. - The web host enforces `Warning` as the minimum for @@ -52,8 +59,8 @@ It applies to API middleware, SignalR transport request logging, queue/worker lo Focused redaction checks: ```powershell -dotnet test backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj -c Release --filter "FullyQualifiedName~SensitiveDataRedactorTests|FullyQualifiedName~OpenAiLlmProviderTests|FullyQualifiedName~CaptureRequestContractTests|FullyQualifiedName~CaptureServiceTests|FullyQualifiedName~OpsCliServiceTests" -$env:Llm__EnableLiveProviders='false'; $env:Llm__AllowLiveProvidersInDevelopment='false'; $env:Llm__Provider='Mock'; dotnet test backend/tests/Taskdeck.Api.Tests/Taskdeck.Api.Tests.csproj -c Release --filter "FullyQualifiedName~LoggingProviderConfigurationTests|FullyQualifiedName~UnhandledExceptionMiddlewareTests|FullyQualifiedName~OutboundWebhookDeliveryWorkerTests|FullyQualifiedName~ProposalHousekeepingWorkerTests|FullyQualifiedName~ObservabilityConfigurationTests|FullyQualifiedName~ProtectedOutboundTelemetryHandlerTests|FullyQualifiedName~CaptureApiTests|FullyQualifiedName~LlmQueueApiTests" +dotnet test backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj -c Release --filter "FullyQualifiedName~SensitiveDataRedactorTests|FullyQualifiedName~OpenAiLlmProviderTests|FullyQualifiedName~CaptureRequestContractTests|FullyQualifiedName~CaptureServiceTests|FullyQualifiedName~OpsCliServiceTests|FullyQualifiedName~AgentRuntimeTests" +$env:Llm__EnableLiveProviders='false'; $env:Llm__AllowLiveProvidersInDevelopment='false'; $env:Llm__Provider='Mock'; dotnet test backend/tests/Taskdeck.Api.Tests/Taskdeck.Api.Tests.csproj -c Release --filter "FullyQualifiedName~LoggingProviderConfigurationTests|FullyQualifiedName~UnhandledExceptionMiddlewareTests|FullyQualifiedName~OutboundWebhookDeliveryWorkerTests|FullyQualifiedName~ProposalHousekeepingWorkerTests|FullyQualifiedName~ObservabilityConfigurationTests|FullyQualifiedName~ProtectedOutboundTelemetryHandlerTests|FullyQualifiedName~CaptureApiTests|FullyQualifiedName~LlmQueueApiTests|FullyQualifiedName~ProposalToolsErrorSafetyTests|FullyQualifiedName~ProposalResourcesErrorSafetyTests" ``` Full backend regression: diff --git a/frontend/taskdeck-web/package-lock.json b/frontend/taskdeck-web/package-lock.json index 4f4514076..25048151f 100644 --- a/frontend/taskdeck-web/package-lock.json +++ b/frontend/taskdeck-web/package-lock.json @@ -2418,29 +2418,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -6199,40 +6213,6 @@ "postcss": "^8.1.0" } }, - "node_modules/autoprefixer/node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7716,9 +7696,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { diff --git a/frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue b/frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue index de8fd1a31..8ad3f7fb6 100644 --- a/frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue +++ b/frontend/taskdeck-web/src/components/board/BoardSettingsModal.vue @@ -1,10 +1,11 @@ diff --git a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts index b7c87e668..ab25efec0 100644 --- a/frontend/taskdeck-web/src/composables/useBoardRealtime.ts +++ b/frontend/taskdeck-web/src/composables/useBoardRealtime.ts @@ -30,7 +30,7 @@ function getAccessToken(): string { } export interface BoardRealtimeControllerOptions { - fetchBoard: (boardId: string) => Promise + fetchBoard: (boardId: string, options: { intent: 'background' }) => Promise onPresenceChanged?: (snapshot: BoardPresenceSnapshot) => void } @@ -52,6 +52,7 @@ export function createBoardRealtimeController( let editingCardId: string | null = null let fallbackTimer: ReturnType | null = null let refreshInFlight = false + let pendingMutationRefreshBoardId: string | null = null let mutationDebounceTimer: ReturnType | null = null const stopFallbackPolling = () => { @@ -70,7 +71,7 @@ export function createBoardRealtimeController( return } - void options.fetchBoard(boardId).catch(() => { + void options.fetchBoard(boardId, { intent: 'background' }).catch(() => { // Keep fallback resilient; fetch failures are already surfaced by store-level handling. }) }, FALLBACK_POLL_INTERVAL_MS) @@ -83,6 +84,33 @@ export function createBoardRealtimeController( } } + const startMutationRefresh = (boardId: string) => { + if (subscribedBoardId !== boardId || requestedBoardId !== boardId) { + return + } + + if (refreshInFlight) { + pendingMutationRefreshBoardId = boardId + return + } + + refreshInFlight = true + void options + .fetchBoard(boardId, { intent: 'background' }) + .catch(() => { + // Background refresh failures must not escape the realtime loop. + }) + .finally(() => { + refreshInFlight = false + const pendingBoardId = pendingMutationRefreshBoardId + pendingMutationRefreshBoardId = null + + if (pendingBoardId) { + startMutationRefresh(pendingBoardId) + } + }) + } + const handleBoardMutation = (event: BoardRealtimeEvent) => { if ( !subscribedBoardId || @@ -98,17 +126,11 @@ export function createBoardRealtimeController( mutationDebounceTimer = setTimeout(() => { mutationDebounceTimer = null - // Skip if a refresh is already in-flight (started by a previous debounced - // call that hasn't resolved yet). - if (refreshInFlight || !subscribedBoardId || subscribedBoardId !== requestedBoardId) { + if (!subscribedBoardId || subscribedBoardId !== requestedBoardId) { return } - const boardId = subscribedBoardId - refreshInFlight = true - void options.fetchBoard(boardId).finally(() => { - refreshInFlight = false - }) + startMutationRefresh(subscribedBoardId) }, MUTATION_DEBOUNCE_MS) } @@ -234,6 +256,7 @@ export function createBoardRealtimeController( // Cancel any debounced mutation fetch from the previous board as soon as // navigation changes intent, rather than waiting for the connection move. cancelMutationDebounce() + pendingMutationRefreshBoardId = null return queueBoardSubscription(boardId, generation) } @@ -260,6 +283,7 @@ export function createBoardRealtimeController( subscriptionGeneration++ stopFallbackPolling() cancelMutationDebounce() + pendingMutationRefreshBoardId = null editingCardId = null if (!connection) { diff --git a/frontend/taskdeck-web/src/composables/useDialogFocusManagement.ts b/frontend/taskdeck-web/src/composables/useDialogFocusManagement.ts new file mode 100644 index 000000000..6d77e7d3f --- /dev/null +++ b/frontend/taskdeck-web/src/composables/useDialogFocusManagement.ts @@ -0,0 +1,87 @@ +import { nextTick, onUnmounted, watch, type Ref } from 'vue' + +const FOCUSABLE_SELECTOR = + 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])' + +export interface DialogFocusManagementOptions { + isOpen: () => boolean + dialogRef: Ref + initialFocus?: (dialog: HTMLElement) => HTMLElement | null +} + +/** + * Shared modal focus lifecycle for Taskdeck dialogs. + * + * Captures and restores the opener, moves focus into the dialog after render, + * and keeps forward/backward Tab movement inside the active dialog. Escape is + * deliberately not handled here: each surface registers with the shared + * escape stack according to its own close contract. + */ +export function useDialogFocusManagement(options: DialogFocusManagementOptions) { + let previouslyFocusedElement: HTMLElement | null = null + + function restoreFocus() { + if (previouslyFocusedElement?.isConnected) { + previouslyFocusedElement.focus() + } + previouslyFocusedElement = null + } + + function focusDialog() { + const dialog = options.dialogRef.value + if (!dialog) return + const initialTarget = options.initialFocus?.(dialog) ?? dialog + initialTarget.focus() + } + + function trapFocus(event: KeyboardEvent) { + if (event.key !== 'Tab' || !options.dialogRef.value) return + + const focusableElements = Array.from( + options.dialogRef.value.querySelectorAll(FOCUSABLE_SELECTOR), + ) + + if (focusableElements.length === 0) { + event.preventDefault() + return + } + + const first = focusableElements[0]! + const last = focusableElements[focusableElements.length - 1]! + const activeIndex = focusableElements.indexOf(document.activeElement as HTMLElement) + + if (event.shiftKey && activeIndex <= 0) { + event.preventDefault() + last.focus() + } else if ( + !event.shiftKey && + (activeIndex === -1 || activeIndex === focusableElements.length - 1) + ) { + event.preventDefault() + first.focus() + } + } + + watch( + options.isOpen, + async (isOpen, wasOpen) => { + if (isOpen) { + if (!wasOpen) { + previouslyFocusedElement = + document.activeElement instanceof HTMLElement ? document.activeElement : null + } + await nextTick() + if (options.isOpen()) focusDialog() + } else if (wasOpen) { + restoreFocus() + } + }, + { immediate: true }, + ) + + // A parent commonly removes a dialog with v-if while its `isOpen` prop is + // still true, so unmount must restore independently of the watcher. + onUnmounted(restoreFocus) + + return { trapFocus } +} diff --git a/frontend/taskdeck-web/src/composables/useKeyboardShortcuts.ts b/frontend/taskdeck-web/src/composables/useKeyboardShortcuts.ts index f541efe5b..59354768d 100644 --- a/frontend/taskdeck-web/src/composables/useKeyboardShortcuts.ts +++ b/frontend/taskdeck-web/src/composables/useKeyboardShortcuts.ts @@ -28,11 +28,21 @@ export interface ShortcutConfig { */ export function useKeyboardShortcuts(shortcuts: ShortcutConfig[]) { const handleKeyDown = (event: KeyboardEvent) => { - // Ignore if typing in input/textarea (except Escape key) - const target = event.target as HTMLElement - const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' + // Text-entry controls own every key except Escape. Other interactive + // controls own native activation keys, while board-navigation keys remain + // available from card/collapse buttons for the roving-focus model. + const target = event.target instanceof Element ? event.target : null + const isTextEntry = Boolean(target?.closest( + 'input, textarea, select, [contenteditable]:not([contenteditable="false"])', + )) + const isActivationControl = Boolean(target?.closest( + 'button, a[href], [role="button"], [role="menuitem"], [role="option"], [role="tab"]', + )) - if (isTyping && event.key !== 'Escape') { + if ( + (isTextEntry && event.key !== 'Escape') || + (isActivationControl && (event.key === 'Enter' || event.key === ' ')) + ) { return } diff --git a/frontend/taskdeck-web/src/composables/useRealtimeSafeDialogDraft.ts b/frontend/taskdeck-web/src/composables/useRealtimeSafeDialogDraft.ts new file mode 100644 index 000000000..04a2ceacb --- /dev/null +++ b/frontend/taskdeck-web/src/composables/useRealtimeSafeDialogDraft.ts @@ -0,0 +1,80 @@ +import { watch } from 'vue' + +export interface RealtimeSafeDialogDraftOptions { + isOpen: () => boolean + source: () => T + sourceKey: (source: T) => string + seed: (source: T) => void + /** + * Fields whose local draft should win over a same-entity realtime refresh. + * Each field's accepted source baseline advances only when that field adopts + * a refresh. While a dialog action is busy, every baseline is held so a + * failed save or cancellation can resume from the last draft state. + */ + fields: RealtimeSafeDialogDraftField[] + /** Dialog actions such as save/archive may temporarily own all fields. */ + isBusy?: () => boolean +} + +export interface RealtimeSafeDialogDraftField { + sourceValue: (source: T) => V + draftValue: () => V + apply: (value: V) => void + equals?: (draftValue: V, sourceValue: V) => boolean +} + +/** + * Re-seeds a dialog from live state without clobbering an in-progress draft. + * + * Opening the dialog and switching to a different entity always use the latest + * source. A same-entity reference replacement (for example a realtime board + * refresh) updates only fields that still match their last accepted server + * value. A dirty field retains that baseline even when a later server value + * happens to equal its draft, so a subsequent refresh cannot erase the edit. + * Each dialog supplies field-specific draft accessors so an untouched sibling + * can follow the collaborator while a locally edited field remains intact. + */ +export function useRealtimeSafeDialogDraft(options: RealtimeSafeDialogDraftOptions) { + let seededKey: string | null = null + let sourceSnapshot: unknown[] | null = null + + const readSourceSnapshot = (source: T) => + options.fields.map((field) => field.sourceValue(source)) + + watch( + () => [options.source(), options.isOpen(), options.isBusy?.() ?? false] as const, + ([source, isOpen], previous) => { + const wasOpen = previous?.[1] ?? false + if (!isOpen) { + seededKey = null + sourceSnapshot = null + return + } + + const sourceKey = options.sourceKey(source) + if (!wasOpen || seededKey !== sourceKey || sourceSnapshot === null) { + options.seed(source) + seededKey = sourceKey + sourceSnapshot = readSourceSnapshot(source) + return + } + + const nextSourceSnapshot = readSourceSnapshot(source) + if (options.isBusy?.()) return + + const acceptedSourceSnapshot = sourceSnapshot + options.fields.forEach((field, index) => { + const previousSourceValue = acceptedSourceSnapshot[index] as never + const sourceValue = nextSourceSnapshot[index] as never + const draftValue = field.draftValue() as never + if (field.equals + ? field.equals(draftValue, previousSourceValue) + : Object.is(draftValue, previousSourceValue)) { + field.apply(sourceValue) + acceptedSourceSnapshot[index] = sourceValue + } + }) + }, + { immediate: true }, + ) +} diff --git a/frontend/taskdeck-web/src/locales/en/boardDetail.ts b/frontend/taskdeck-web/src/locales/en/boardDetail.ts index 7ca05657f..35452b07c 100644 --- a/frontend/taskdeck-web/src/locales/en/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/en/boardDetail.ts @@ -92,7 +92,6 @@ export default { archiveConfirmCancel: 'Keep it here', restore: 'Restore board', saveError: 'Could not save the board. Please try again.', - archiveError: 'Could not archive the board. Please try again.', restoreError: 'Could not restore the board. Please try again.', }, } diff --git a/frontend/taskdeck-web/src/locales/es/boardDetail.ts b/frontend/taskdeck-web/src/locales/es/boardDetail.ts index bf92b529c..b41e3d6df 100644 --- a/frontend/taskdeck-web/src/locales/es/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/es/boardDetail.ts @@ -82,7 +82,6 @@ export default { archiveConfirmCancel: 'Déjalo aquí', restore: 'Restaurar el tablero', saveError: 'No se pudo guardar el tablero. Inténtalo de nuevo.', - archiveError: 'No se pudo archivar el tablero. Inténtalo de nuevo.', restoreError: 'No se pudo restaurar el tablero. Inténtalo de nuevo.', }, } diff --git a/frontend/taskdeck-web/src/locales/it/boardDetail.ts b/frontend/taskdeck-web/src/locales/it/boardDetail.ts index 513b90618..02c048e46 100644 --- a/frontend/taskdeck-web/src/locales/it/boardDetail.ts +++ b/frontend/taskdeck-web/src/locales/it/boardDetail.ts @@ -82,7 +82,6 @@ export default { archiveConfirmCancel: 'Lasciala qui', restore: 'Ripristina la bacheca', saveError: 'Non è stato possibile salvare la bacheca. Riprova.', - archiveError: 'Non è stato possibile archiviare la bacheca. Riprova.', restoreError: 'Non è stato possibile ripristinare la bacheca. Riprova.', }, } diff --git a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts index 46e53bea3..13e828658 100644 --- a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts +++ b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts @@ -15,11 +15,33 @@ import type { BoardHelpers } from './boardStoreHelpers' // ActivityView, ReviewView, etc.) can call fetchBoards on mount in quick // succession; the throttle guard prevents duplicate network round-trips. const FETCH_BOARDS_THROTTLE_MS = 5000 +const BOARD_ACCESS_REVOKED_MESSAGE = 'You no longer have access to this board' + +export type BoardFetchIntent = 'explicit' | 'background' + +export interface BoardFetchOptions { + intent?: BoardFetchIntent +} + +interface ActiveBoardFetch { + boardId: string + intent: BoardFetchIntent + generation: number + controller: AbortController + promise: Promise +} + +interface QueuedBackgroundBoardFetch { + boardId: string + promise: Promise + resolve: (committed: boolean) => void +} export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) { let lastFetchBoardsAt = 0 let boardFetchGeneration = 0 - let activeBoardFetchController: AbortController | null = null + let activeBoardFetch: ActiveBoardFetch | null = null + let queuedBackgroundBoardFetch: QueuedBackgroundBoardFetch | null = null async function fetchBoards(search?: string, includeArchived = false) { const now = Date.now() @@ -60,78 +82,195 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) } } - async function fetchBoard(id: string): Promise { + function settleQueuedBackgroundBoardFetch(committed = false) { + const queued = queuedBackgroundBoardFetch + queuedBackgroundBoardFetch = null + queued?.resolve(committed) + } + + function queueBackgroundBoardFetch(id: string): Promise { + if (queuedBackgroundBoardFetch?.boardId === id) { + return queuedBackgroundBoardFetch.promise + } + + settleQueuedBackgroundBoardFetch() + let resolve!: (committed: boolean) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + queuedBackgroundBoardFetch = { boardId: id, promise, resolve } + return promise + } + + function drainQueuedBackgroundBoardFetch(completedFetch: ActiveBoardFetch) { + const queued = queuedBackgroundBoardFetch + if (!queued || queued.boardId !== completedFetch.boardId) { + return + } + + queuedBackgroundBoardFetch = null + void startBoardFetch(queued.boardId, 'background').then(queued.resolve, () => { + queued.resolve(false) + }) + } + + function cancelBackgroundBoardFetch(boardId?: string) { + if ( + queuedBackgroundBoardFetch && + (boardId === undefined || queuedBackgroundBoardFetch.boardId === boardId) + ) { + settleQueuedBackgroundBoardFetch() + } + + const active = activeBoardFetch + if ( + active?.intent === 'background' && + (boardId === undefined || active.boardId === boardId) + ) { + boardFetchGeneration++ + active.controller.abort() + if (activeBoardFetch === active) { + activeBoardFetch = null + } + } + } + + function fetchBoard(id: string, options: BoardFetchOptions = {}): Promise { + const intent = options.intent ?? 'explicit' + + if (intent === 'background' && activeBoardFetch) { + if (activeBoardFetch.boardId !== id) { + return Promise.resolve(false) + } + + if (activeBoardFetch.intent === 'explicit') { + return queueBackgroundBoardFetch(id) + } + + return activeBoardFetch.promise + } + + if (intent === 'explicit') { + // A route load or Retry includes all mutations observed before it began, + // so it supersedes any older queued background refresh. + settleQueuedBackgroundBoardFetch() + } + + return startBoardFetch(id, intent) + } + + function startBoardFetch(id: string, intent: BoardFetchIntent): Promise { const requestGeneration = ++boardFetchGeneration - activeBoardFetchController?.abort() + activeBoardFetch?.controller.abort() const controller = new AbortController() - activeBoardFetchController = controller + const mutationEpoch = helpers.getBoardDetailMutationEpoch(id) + const request = { + boardId: id, + intent, + generation: requestGeneration, + controller, + promise: Promise.resolve(false), + } satisfies ActiveBoardFetch + + const isCurrentGeneration = () => requestGeneration === boardFetchGeneration + const isCommitEligible = () => + isCurrentGeneration() && helpers.getBoardDetailMutationEpoch(id) === mutationEpoch + + const performFetch = async (): Promise => { + if (helpers.isDemoMode) { + if (intent === 'explicit') { + state.loading.value = true + state.error.value = null + } + const demo = buildDemoBoardDetail(id) + if (!isCommitEligible()) { + return false + } - if (helpers.isDemoMode) { - state.loading.value = true - state.error.value = null - const demo = buildDemoBoardDetail(id) - if (requestGeneration === boardFetchGeneration) { state.currentBoard.value = demo.board state.currentBoardCards.value = demo.cards state.currentBoardLabels.value = [] state.cardCommentsByCardId.value = {} - state.loading.value = false - activeBoardFetchController = null + if (intent === 'explicit') { + state.loading.value = false + } return true } - return false - } + try { + if (intent === 'explicit') { + state.loading.value = true + state.error.value = null + } + const readOptions: BoardReadOptions = { + signal: controller.signal, + timeout: BOARD_REQUEST_TIMEOUT_MS, + skipRetry: true, + } + const [board, cards, labels] = await Promise.all([ + boardsApi.getBoard(id, readOptions), + cardsApi.getCards(id, undefined, readOptions), + labelsApi.getLabels(id, readOptions), + ]) - try { - state.loading.value = true - state.error.value = null - const readOptions: BoardReadOptions = { - signal: controller.signal, - timeout: BOARD_REQUEST_TIMEOUT_MS, - skipRetry: true, - } - const [board, cards, labels] = await Promise.all([ - boardsApi.getBoard(id, readOptions), - cardsApi.getCards(id, undefined, readOptions), - labelsApi.getLabels(id, readOptions), - ]) - - if (requestGeneration !== boardFetchGeneration) { - return false - } + if (!isCommitEligible()) { + return false + } - const cardCounts = cards.reduce((counts, card) => { - counts.set(card.columnId, (counts.get(card.columnId) ?? 0) + 1) - return counts - }, new Map()) - board.columns.forEach((column) => { - column.cardCount = cardCounts.get(column.id) ?? 0 - }) - - state.currentBoard.value = board - state.currentBoardCards.value = cards - state.currentBoardLabels.value = labels - state.cardCommentsByCardId.value = {} - return true - } catch (e: unknown) { - if (requestGeneration !== boardFetchGeneration || axios.isCancel(e)) { - return false - } + const cardCounts = cards.reduce((counts, card) => { + counts.set(card.columnId, (counts.get(card.columnId) ?? 0) + 1) + return counts + }, new Map()) + board.columns.forEach((column) => { + column.cardCount = cardCounts.get(column.id) ?? 0 + }) - // Ensure held-open siblings are cancelled before exposing the failure to - // the caller. The next explicit Retry starts a new generation. - controller.abort() - helpers.handleApiError(e, 'Failed to fetch board') - throw e - } finally { - if (requestGeneration === boardFetchGeneration) { - state.loading.value = false - if (activeBoardFetchController === controller) { - activeBoardFetchController = null + state.currentBoard.value = board + state.currentBoardCards.value = cards + state.currentBoardLabels.value = labels + state.cardCommentsByCardId.value = {} + return true + } catch (e: unknown) { + // Ensure held-open siblings are cancelled before exposing a current + // explicit failure or silently dropping stale/background work. + controller.abort() + if (!isCommitEligible() || axios.isCancel(e)) { + return false + } + + if (intent === 'background') { + const status = (e as { response?: { status?: number } } | null)?.response?.status + if (status === 403) { + helpers.handleApiError( + new Error(BOARD_ACCESS_REVOKED_MESSAGE), + BOARD_ACCESS_REVOKED_MESSAGE, + ) + } + return false + } + + helpers.handleApiError(e, 'Failed to fetch board') + throw e + } finally { + if (intent === 'explicit' && isCurrentGeneration()) { + state.loading.value = false } } } + + const promise = performFetch().finally(() => { + if (activeBoardFetch !== request) { + return + } + + activeBoardFetch = null + if (request.intent === 'explicit') { + drainQueuedBackgroundBoardFetch(request) + } + }) + request.promise = promise + activeBoardFetch = request + return promise } async function createBoard(board: CreateBoardDto) { @@ -222,6 +361,7 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) return { fetchBoards, fetchBoard, + cancelBackgroundBoardFetch, createBoard, updateBoard, deleteBoard, diff --git a/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts b/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts index 3f014d9ed..d4b483344 100644 --- a/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts +++ b/frontend/taskdeck-web/src/store/board/boardStoreHelpers.ts @@ -8,6 +8,7 @@ import type { BoardState } from './boardState' export function createBoardHelpers(state: BoardState) { const toast = useToastStore() + const boardDetailMutationEpochs = new Map() const handleApiError = (err: unknown, fallback: string) => { const message = getErrorMessage(err, fallback) @@ -42,6 +43,13 @@ export function createBoardHelpers(state: BoardState) { column.cardCount = Math.max(0, nextCount) } + const getBoardDetailMutationEpoch = (boardId: string) => + boardDetailMutationEpochs.get(boardId) ?? 0 + + const markBoardDetailMutation = (boardId: string) => { + boardDetailMutationEpochs.set(boardId, getBoardDetailMutationEpoch(boardId) + 1) + } + return { toast, handleApiError, @@ -49,6 +57,8 @@ export function createBoardHelpers(state: BoardState) { isHttpNotFound, isHttpConflict, updateColumnCardCount, + getBoardDetailMutationEpoch, + markBoardDetailMutation, isDemoMode, } } diff --git a/frontend/taskdeck-web/src/store/board/cardStore.ts b/frontend/taskdeck-web/src/store/board/cardStore.ts index de1eb98d3..ad0899d50 100644 --- a/frontend/taskdeck-web/src/store/board/cardStore.ts +++ b/frontend/taskdeck-web/src/store/board/cardStore.ts @@ -39,6 +39,7 @@ export function createCardActions(state: BoardState, helpers: BoardHelpers) { state.loading.value = true state.error.value = null const newCard = await cardsApi.createCard(boardId, card) + helpers.markBoardDetailMutation(boardId) state.currentBoardCards.value.push(newCard) helpers.updateColumnCardCount(newCard.columnId, 1) helpers.toast.success(`Card "${newCard.title.trim()}" created successfully`) @@ -62,6 +63,7 @@ export function createCardActions(state: BoardState, helpers: BoardHelpers) { expectedUpdatedAt: card.expectedUpdatedAt ?? existingCard?.updatedAt ?? null, } const updatedCard = await cardsApi.updateCard(boardId, cardId, request) + helpers.markBoardDetailMutation(boardId) // Update the card in the store const index = state.currentBoardCards.value.findIndex((c) => c.id === cardId) @@ -90,6 +92,7 @@ export function createCardActions(state: BoardState, helpers: BoardHelpers) { state.error.value = null const existingCard = state.currentBoardCards.value.find((card) => card.id === cardId) await cardsApi.deleteCard(boardId, cardId) + helpers.markBoardDetailMutation(boardId) // Remove the card from the store state.currentBoardCards.value = state.currentBoardCards.value.filter((c) => c.id !== cardId) @@ -130,6 +133,7 @@ export function createCardActions(state: BoardState, helpers: BoardHelpers) { targetColumnId, targetPosition, }) + helpers.markBoardDetailMutation(boardId) if (existingCardIndex !== -1) { state.currentBoardCards.value.splice(existingCardIndex, 1) diff --git a/frontend/taskdeck-web/src/store/board/columnStore.ts b/frontend/taskdeck-web/src/store/board/columnStore.ts index db2a3d762..73db0efbc 100644 --- a/frontend/taskdeck-web/src/store/board/columnStore.ts +++ b/frontend/taskdeck-web/src/store/board/columnStore.ts @@ -13,6 +13,7 @@ export function createColumnActions(state: BoardState, helpers: BoardHelpers) { state.loading.value = true state.error.value = null const newColumn = await columnsApi.createColumn(boardId, column) + helpers.markBoardDetailMutation(boardId) if (state.currentBoard.value && state.currentBoard.value.id === boardId) { // A realtime refresh can install the new column before this request resolves. @@ -42,6 +43,7 @@ export function createColumnActions(state: BoardState, helpers: BoardHelpers) { state.loading.value = true state.error.value = null const updatedColumn = await columnsApi.updateColumn(boardId, columnId, column) + helpers.markBoardDetailMutation(boardId) // Update column in current board if (state.currentBoard.value && state.currentBoard.value.id === boardId) { @@ -67,6 +69,7 @@ export function createColumnActions(state: BoardState, helpers: BoardHelpers) { state.loading.value = true state.error.value = null await columnsApi.deleteColumn(boardId, columnId) + helpers.markBoardDetailMutation(boardId) // Remove column from current board if (state.currentBoard.value && state.currentBoard.value.id === boardId) { @@ -95,6 +98,7 @@ export function createColumnActions(state: BoardState, helpers: BoardHelpers) { state.loading.value = true state.error.value = null const reorderedColumns = await columnsApi.reorderColumns(boardId, columnIds) + helpers.markBoardDetailMutation(boardId) // Update columns in current board with reordered list if (state.currentBoard.value && state.currentBoard.value.id === boardId) { diff --git a/frontend/taskdeck-web/src/store/board/index.ts b/frontend/taskdeck-web/src/store/board/index.ts index f2f4c626b..0009e4975 100644 --- a/frontend/taskdeck-web/src/store/board/index.ts +++ b/frontend/taskdeck-web/src/store/board/index.ts @@ -3,6 +3,7 @@ export type { CardFilters, BoardState } from './boardState' export { createBoardHelpers } from './boardStoreHelpers' export type { BoardHelpers } from './boardStoreHelpers' export { createBoardCrudActions } from './boardCrudStore' +export type { BoardFetchIntent, BoardFetchOptions } from './boardCrudStore' export { createColumnActions } from './columnStore' export { createCardActions } from './cardStore' export { createCardCommentActions } from './cardCommentStore' diff --git a/frontend/taskdeck-web/src/store/boardStore.ts b/frontend/taskdeck-web/src/store/boardStore.ts index 1b5e9c096..adbbc686d 100644 --- a/frontend/taskdeck-web/src/store/boardStore.ts +++ b/frontend/taskdeck-web/src/store/boardStore.ts @@ -9,6 +9,7 @@ import { createLabelActions, createCardFilterActions, createBoardUiActions, + type BoardFetchOptions, } from './board' // Re-export the CardFilters type so existing consumers keep working @@ -31,8 +32,8 @@ export const useBoardStore = defineStore('board', () => { const ui = createBoardUiActions(state) // Detail loads commit board, cards, and labels together in boardCrud. - async function fetchBoard(id: string) { - return boardCrud.fetchBoard(id) + async function fetchBoard(id: string, options?: BoardFetchOptions) { + return boardCrud.fetchBoard(id, options) } return { @@ -57,6 +58,7 @@ export const useBoardStore = defineStore('board', () => { // Actions — board CRUD fetchBoards: boardCrud.fetchBoards, fetchBoard, + cancelBackgroundBoardFetch: boardCrud.cancelBackgroundBoardFetch, createBoard: boardCrud.createBoard, updateBoard: boardCrud.updateBoard, deleteBoard: boardCrud.deleteBoard, diff --git a/frontend/taskdeck-web/src/store/captureStore.ts b/frontend/taskdeck-web/src/store/captureStore.ts index 6046431ed..a442ba675 100644 --- a/frontend/taskdeck-web/src/store/captureStore.ts +++ b/frontend/taskdeck-web/src/store/captureStore.ts @@ -35,6 +35,8 @@ type DetailLoadOptions = { export const BATCH_TRIAGE_POLL_INTERVAL_MS = 3_000 export const BATCH_TRIAGE_POLL_MAX_DURATION_MS = 60_000 +const BATCH_TRIAGE_POLL_TIMEOUT_MESSAGE = + 'Automatic checking stopped after 60 seconds. Triage may still be running. Use Refresh Detail to check the result.' type CreateItemOptions = { /** @@ -538,6 +540,17 @@ export const useCaptureStore = defineStore('capture', () => { } } + function stopAtDeadline() { + if (stopped) return + // The batch write already succeeded. A deadline only means automatic + // checking stopped; the server-side triage may still be running. + if (!isComplete()) { + batchError.value = BATCH_TRIAGE_POLL_TIMEOUT_MESSAGE + toast.warning(BATCH_TRIAGE_POLL_TIMEOUT_MESSAGE, 0) + } + stop() + } + function isComplete(): boolean { return trackedIds.every((id) => { const summary = items.value.find((item) => item.id === id) @@ -624,7 +637,7 @@ export const useCaptureStore = defineStore('capture', () => { } // A separate deadline timer aborts an in-flight request at the boundary; // counting ticks alone would let one slow HTTP request exceed 60 seconds. - deadlineTimerId = setTimeout(stop, BATCH_TRIAGE_POLL_MAX_DURATION_MS) + deadlineTimerId = setTimeout(stopAtDeadline, BATCH_TRIAGE_POLL_MAX_DURATION_MS) scheduleNext() return stop } diff --git a/frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts b/frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts index b5d743fdd..6bd546d32 100644 --- a/frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/BoardSettingsModal.spec.ts @@ -81,6 +81,68 @@ describe('BoardSettingsModal', () => { expect(wrapper.text()).toContain('Active') }) + it('preserves in-progress edits when realtime replaces the board object', async () => { + const wrapper = mount(BoardSettingsModal, { + props: { + board, + isOpen: true, + }, + }) + + await wrapper.get('#board-name').setValue('My draft board') + await wrapper.get('#board-description').setValue('My draft description') + await wrapper.setProps({ + board: { + ...board, + name: 'Server refresh', + description: 'Server description', + updatedAt: new Date().toISOString(), + }, + }) + + expect((wrapper.get('#board-name').element as HTMLInputElement).value).toBe('My draft board') + expect((wrapper.get('#board-description').element as HTMLTextAreaElement).value).toBe( + 'My draft description', + ) + }) + + it('saves a dirty field after realtime briefly matches it and keeps its sibling current', async () => { + const wrapper = mount(BoardSettingsModal, { + props: { board, isOpen: true }, + }) + + await wrapper.get('#board-name').setValue('My draft board') + await wrapper.setProps({ + board: { ...board, name: 'My draft board', description: 'First server description' }, + }) + + expect((wrapper.get('#board-name').element as HTMLInputElement).value).toBe('My draft board') + expect((wrapper.get('#board-description').element as HTMLTextAreaElement).value).toBe( + 'First server description', + ) + + await wrapper.setProps({ + board: { ...board, name: 'Later server name', description: 'Latest server description' }, + }) + + expect((wrapper.get('#board-name').element as HTMLInputElement).value).toBe('My draft board') + expect((wrapper.get('#board-description').element as HTMLTextAreaElement).value).toBe( + 'Latest server description', + ) + + mockStore.updateBoard.mockClear() + const saveButton = wrapper + .findAll('button') + .find((button) => button.text().includes('Save Changes')) + await saveButton?.trigger('click') + + expect(mockStore.updateBoard).toHaveBeenCalledWith('board-1', { + name: 'My draft board', + description: null, + isArchived: null, + }) + }) + it('should emit close event when close button is clicked', async () => { const wrapper = mount(BoardSettingsModal, { props: { @@ -277,6 +339,53 @@ describe('BoardSettingsModal', () => { confirmSpy.mockRestore() }) + it('reconciles an untouched field when a busy restore fails without another refresh', async () => { + const archivedBoard = { ...board, isArchived: true } + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + let rejectUpdate!: (error: Error) => void + mockStore.updateBoard.mockImplementationOnce( + () => new Promise((_, reject) => { rejectUpdate = reject }), + ) + + const wrapper = mount(BoardSettingsModal, { + props: { board: archivedBoard, isOpen: true }, + }) + + await wrapper.get('#board-name').setValue('My draft board') + const restoreButton = wrapper + .findAll('button') + .find((btn) => btn.text().includes('Restore Board')) + void restoreButton?.trigger('click') + await wrapper.vm.$nextTick() + + await wrapper.setProps({ + board: { ...archivedBoard, name: 'Busy server name', description: 'Busy server description' }, + }) + rejectUpdate(new Error('restore failed')) + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + expect((wrapper.get('#board-name').element as HTMLInputElement).value).toBe('My draft board') + expect((wrapper.get('#board-description').element as HTMLTextAreaElement).value).toBe( + 'Busy server description', + ) + + mockStore.updateBoard.mockClear() + await wrapper + .findAll('button') + .find((btn) => btn.text().includes('Save Changes')) + ?.trigger('click') + await wrapper.vm.$nextTick() + + expect(mockStore.updateBoard).toHaveBeenCalledWith('board-1', { + name: 'My draft board', + description: null, + isArchived: null, + }) + + confirmSpy.mockRestore() + }) + it('should show restore action when board is archived', () => { const archivedBoard = { ...board, isArchived: true } diff --git a/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts b/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts index 00a1c9751..c74d10807 100644 --- a/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/CardItem.coverage.spec.ts @@ -216,7 +216,7 @@ describe('CardItem — selection and click', () => { const card = createCard() const wrapper = mount(CardItem, { props: { card } }) - await wrapper.find('.td-board-card').trigger('keydown.enter') + await wrapper.find('.td-board-card').trigger('keydown', { key: 'Enter' }) expect(wrapper.emitted('click')).toBeTruthy() expect(wrapper.emitted('click')![0]).toEqual([card]) diff --git a/frontend/taskdeck-web/src/tests/components/CardItem.spec.ts b/frontend/taskdeck-web/src/tests/components/CardItem.spec.ts index 9879b9b73..174c3fd40 100644 --- a/frontend/taskdeck-web/src/tests/components/CardItem.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/CardItem.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, it, expect, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import CardItem from '../../components/board/CardItem.vue' import type { Card } from '../../types/board' @@ -23,12 +23,13 @@ function createCard(): Card { afterEach(() => { vi.unstubAllEnvs() + document.body.innerHTML = '' }) describe('CardItem — date display', () => { it('renders formatted due date and overdue indicator when dueDate is in the past', () => { const card = createCard() - card.dueDate = '2020-01-01T00:00:00.000Z' // definitely in the past + card.dueDate = '2020-01-01T00:00:00.000Z' const wrapper = mount(CardItem, { props: { card } }) expect(wrapper.find('.td-board-card__due').exists()).toBe(true) expect(wrapper.find('.td-board-card__due--overdue').exists()).toBe(true) @@ -47,10 +48,8 @@ describe('CardItem — date display', () => { vi.stubEnv('TZ', 'America/Los_Angeles') const card = createCard() card.dueDate = '2026-08-23T00:00:00.000Z' - const wrapper = mount(CardItem, { props: { card } }) const dueText = wrapper.get('.td-board-card__due').text() - expect(dueText).toContain('23') expect(dueText).not.toContain('22') }) @@ -58,12 +57,7 @@ describe('CardItem — date display', () => { describe('CardItem drag guardrails', () => { it('exposes an explicit enlarged drag handle control', () => { - const wrapper = mount(CardItem, { - props: { - card: createCard(), - }, - }) - + const wrapper = mount(CardItem, { props: { card: createCard() } }) const handle = wrapper.get('.td-card-drag-handle') expect(handle.attributes('data-action')).toBe('drag-card-handle') expect(handle.attributes('draggable')).toBe('true') @@ -75,34 +69,22 @@ describe('CardItem drag guardrails', () => { }) it('blocks dragstart when not initiated from drag handle', async () => { - const wrapper = mount(CardItem, { - props: { - card: createCard(), - }, - }) - + const wrapper = mount(CardItem, { props: { card: createCard() } }) const setData = vi.fn() await wrapper.get('[data-card-id]').trigger('dragstart', { dataTransfer: { effectAllowed: 'move', setData }, }) - expect(wrapper.emitted('dragstart')).toBeFalsy() expect(setData).not.toHaveBeenCalled() }) it('allows dragstart from the dedicated drag handle', async () => { const card = createCard() - const wrapper = mount(CardItem, { - props: { - card, - }, - }) - + const wrapper = mount(CardItem, { props: { card } }) const setData = vi.fn() await wrapper.get('[data-action="drag-card-handle"]').trigger('dragstart', { dataTransfer: { effectAllowed: 'move', setData }, }) - expect(setData).toHaveBeenCalledWith('text/plain', card.id) expect(wrapper.emitted('dragstart')).toEqual([[card]]) }) @@ -119,36 +101,76 @@ describe('CardItem drag handle — text selection clearing', () => { it('clears an active text selection when the drag handle is mousedown-ed', async () => { const card = createCard() const wrapper = mount(CardItem, { props: { card } }) - - // Build a real DOM range over the card title text and add it to the selection const selection = window.getSelection() - if (!selection) return // JSDOM always provides getSelection; guard for type safety - + if (!selection) return const titleEl = wrapper.get('.td-board-card__title').element const range = document.createRange() range.selectNodeContents(titleEl) selection.removeAllRanges() selection.addRange(range) expect(selection.toString()).toBe(card.title) - - // Trigger mousedown on the drag handle await wrapper.get('[data-action="drag-card-handle"]').trigger('mousedown') - - // Selection must be cleared expect(selection.toString()).toBe('') }) it('does not throw when getSelection returns null', async () => { vi.stubGlobal('getSelection', () => null) + const wrapper = mount(CardItem, { props: { card: createCard() } }) + await expect( + wrapper.get('[data-action="drag-card-handle"]').trigger('mousedown'), + ).resolves.not.toThrow() + vi.unstubAllGlobals() + }) +}) +describe('CardItem keyboard activation', () => { + it('focuses the card after a body click so Enter activates that card', async () => { const wrapper = mount(CardItem, { + attachTo: document.body, props: { card: createCard() }, }) - await expect( - wrapper.get('[data-action="drag-card-handle"]').trigger('mousedown'), - ).resolves.not.toThrow() + await wrapper.get('.td-board-card__title').trigger('click') + expect(document.activeElement).toBe(wrapper.get('[data-card-id]').element) - vi.unstubAllGlobals() + await wrapper.get('[data-card-id]').trigger('keydown', { key: 'Enter' }) + expect(wrapper.emitted('click')).toHaveLength(2) + + wrapper.unmount() + }) + + it('leaves action-bar button activation to the button', async () => { + const wrapper = mount(CardItem, { + attachTo: document.body, + props: { card: createCard() }, + }) + + await wrapper.get('[data-action="drag-card-handle"]').trigger('click') + expect(wrapper.emitted('click')).toBeUndefined() + expect(document.activeElement).toBe(wrapper.get('[data-card-id]').element) + + await wrapper.get('[data-card-id]').trigger('keydown', { key: 'Enter' }) + expect(wrapper.emitted('click')).toHaveLength(1) + + wrapper.unmount() + }) + + it('emits one activation for a complete Enter key press', async () => { + const wrapper = mount(CardItem, { + attachTo: document.body, + props: { card: createCard() }, + }) + + const keydown = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }) + wrapper.get('[data-card-id]').element.dispatchEvent(keydown) + await wrapper.get('[data-card-id]').trigger('keyup', { key: 'Enter' }) + + expect(keydown.defaultPrevented).toBe(true) + expect(wrapper.emitted('click')).toHaveLength(1) + wrapper.unmount() }) }) diff --git a/frontend/taskdeck-web/src/tests/components/ColumnEditModal.spec.ts b/frontend/taskdeck-web/src/tests/components/ColumnEditModal.spec.ts index 66e5e5c90..38c7b232d 100644 --- a/frontend/taskdeck-web/src/tests/components/ColumnEditModal.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/ColumnEditModal.spec.ts @@ -76,6 +76,71 @@ describe('ColumnEditModal', () => { expect(wipCheckbox.element.checked).toBe(false) }) + it('preserves an in-progress edit when realtime replaces the column object', async () => { + const wrapper = mount(ColumnEditModal, { + props: { + column, + isOpen: true, + boardId: 'board-1', + }, + }) + + await wrapper.get('#column-name').setValue('My draft name') + await wrapper.setProps({ + column: { ...column, name: 'Server refresh', updatedAt: new Date().toISOString() }, + }) + + expect((wrapper.get('#column-name').element as HTMLInputElement).value).toBe('My draft name') + }) + + it('reconciles untouched WIP fields while preserving a dirty name', async () => { + const wrapper = mount(ColumnEditModal, { + props: { column, isOpen: true, boardId: 'board-1' }, + }) + + await wrapper.get('#column-name').setValue('My draft name') + await wrapper.setProps({ + column: { ...column, name: 'Server name', wipLimit: 3 }, + }) + + expect((wrapper.get('#column-name').element as HTMLInputElement).value).toBe('My draft name') + expect((wrapper.get('#column-has-wip-limit').element as HTMLInputElement).checked).toBe(true) + expect((wrapper.get('#wip-limit').element as HTMLInputElement).value).toBe('3') + }) + + it('keeps a locally edited WIP pair when realtime clears the remote limit', async () => { + const wrapper = mount(ColumnEditModal, { + props: { column: { ...column, wipLimit: 3 }, isOpen: true, boardId: 'board-1' }, + }) + + await wrapper.get('#wip-limit').setValue(5) + await wrapper.setProps({ column: { ...column, wipLimit: null } }) + + expect((wrapper.get('#column-has-wip-limit').element as HTMLInputElement).checked).toBe(true) + expect((wrapper.get('#wip-limit').element as HTMLInputElement).value).toBe('5') + }) + + it('seeds the latest values when a cancelled dialog is reopened', async () => { + const wrapper = mount(ColumnEditModal, { + props: { + column, + isOpen: true, + boardId: 'board-1', + }, + }) + + await wrapper.get('#column-name').setValue('Cancelled draft') + await wrapper.setProps({ isOpen: false }) + await wrapper.setProps({ + column: { ...column, name: 'Latest server name' }, + isOpen: true, + }) + + expect((wrapper.get('#column-name').element as HTMLInputElement).value).toBe( + 'Latest server name', + ) + }) + it('should show WIP limit input when column has WIP limit', () => { const columnWithWip = { ...column, diff --git a/frontend/taskdeck-web/src/tests/components/ui/TdDialog.spec.ts b/frontend/taskdeck-web/src/tests/components/ui/TdDialog.spec.ts index 8b93f54ab..6585ee824 100644 --- a/frontend/taskdeck-web/src/tests/components/ui/TdDialog.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/ui/TdDialog.spec.ts @@ -78,6 +78,31 @@ describe('TdDialog', () => { wrapper.unmount() }) + it('traps forward and backward Tab movement inside the dialog', async () => { + const wrapper = mount(TdDialog, { + props: { open: true }, + slots: { + default: '', + footer: '', + }, + attachTo: document.body, + }) + await nextTick() + const first = document.querySelector('[data-testid="first"]') as HTMLElement + const last = document.querySelector('[data-testid="last"]') as HTMLElement + + last.focus() + last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })) + expect(document.activeElement).toBe(first) + + first.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }), + ) + expect(document.activeElement).toBe(last) + + wrapper.unmount() + }) + it('registers escape handler when opened', async () => { const wrapper = mount(TdDialog, { props: { open: false }, diff --git a/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts b/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts index 09e2969c6..b3f3462d7 100644 --- a/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useBoardRealtime.spec.ts @@ -140,7 +140,7 @@ describe('createBoardRealtimeController', () => { // The handler debounces the refresh — advance past the debounce window. await vi.advanceTimersByTimeAsync(300) - expect(fetchBoard).toHaveBeenCalledWith('board-1') + expect(fetchBoard).toHaveBeenCalledWith('board-1', { intent: 'background' }) vi.useRealTimers() }) @@ -177,12 +177,106 @@ describe('createBoardRealtimeController', () => { // Advance past the debounce — only one fetch should fire for the burst. await vi.advanceTimersByTimeAsync(300) expect(fetchBoard).toHaveBeenCalledTimes(1) - expect(fetchBoard).toHaveBeenCalledWith('board-1') + expect(fetchBoard).toHaveBeenCalledWith('board-1', { intent: 'background' }) vi.useRealTimers() await controller.stop() }) + it('drains one coalesced mutation refresh after the active refresh succeeds', async () => { + vi.useFakeTimers() + const firstRefresh = createDeferred() + const fetchBoard = vi + .fn() + .mockImplementationOnce(() => firstRefresh.promise) + .mockResolvedValueOnce(undefined) + const controller = createBoardRealtimeController({ fetchBoard }) + + await controller.start('board-1') + callbacks.boardMutation?.({ boardId: 'board-1' }) + await vi.advanceTimersByTimeAsync(300) + expect(fetchBoard).toHaveBeenCalledTimes(1) + + callbacks.boardMutation?.({ boardId: 'board-1' }) + callbacks.boardMutation?.({ boardId: 'board-1' }) + await vi.advanceTimersByTimeAsync(300) + expect(fetchBoard).toHaveBeenCalledTimes(1) + + firstRefresh.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(fetchBoard).toHaveBeenCalledTimes(2) + expect(fetchBoard).toHaveBeenLastCalledWith('board-1', { intent: 'background' }) + + await controller.stop() + }) + + it('clears a retained mutation refresh on route switch and stop', async () => { + vi.useFakeTimers() + const firstRefresh = createDeferred() + const secondRefresh = createDeferred() + const fetchBoard = vi + .fn() + .mockImplementationOnce(() => firstRefresh.promise) + .mockImplementationOnce(() => secondRefresh.promise) + .mockResolvedValue(undefined) + const controller = createBoardRealtimeController({ fetchBoard }) + + await controller.start('board-1') + callbacks.boardMutation?.({ boardId: 'board-1' }) + await vi.advanceTimersByTimeAsync(300) + callbacks.boardMutation?.({ boardId: 'board-1' }) + await vi.advanceTimersByTimeAsync(300) + + await controller.switchBoard('board-2') + firstRefresh.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(fetchBoard).toHaveBeenCalledTimes(1) + + callbacks.boardMutation?.({ boardId: 'board-2' }) + await vi.advanceTimersByTimeAsync(300) + callbacks.boardMutation?.({ boardId: 'board-2' }) + await vi.advanceTimersByTimeAsync(300) + expect(fetchBoard).toHaveBeenCalledTimes(2) + + await controller.stop() + secondRefresh.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(fetchBoard).toHaveBeenCalledTimes(2) + }) + + it('contains a failed background refresh and allows the next mutation to refresh', async () => { + vi.useFakeTimers() + const firstRefresh = createDeferred() + const fetchBoard = vi + .fn() + .mockImplementationOnce(() => firstRefresh.promise) + .mockResolvedValueOnce(undefined) + const controller = createBoardRealtimeController({ fetchBoard }) + + await controller.start('board-1') + callbacks.boardMutation?.({ boardId: 'board-1' }) + await vi.advanceTimersByTimeAsync(300) + expect(fetchBoard).toHaveBeenCalledTimes(1) + + firstRefresh.reject(new Error('background unavailable')) + await Promise.resolve() + await Promise.resolve() + + callbacks.boardMutation?.({ boardId: 'board-1' }) + await vi.advanceTimersByTimeAsync(300) + expect(fetchBoard).toHaveBeenCalledTimes(2) + expect(fetchBoard).toHaveBeenLastCalledWith('board-1', { intent: 'background' }) + + await controller.stop() + }) + it('emits presence snapshots for the currently subscribed board', async () => { const fetchBoard = vi.fn(async () => undefined) const onPresenceChanged = vi.fn() @@ -261,7 +355,7 @@ describe('createBoardRealtimeController', () => { await vi.advanceTimersByTimeAsync(30000) expect(fetchBoard).toHaveBeenCalledTimes(1) - expect(fetchBoard).toHaveBeenCalledWith('board-b') + expect(fetchBoard).toHaveBeenCalledWith('board-b', { intent: 'background' }) mockConnection.state = 'Connected' await callbacks.reconnected?.() @@ -310,7 +404,7 @@ describe('createBoardRealtimeController', () => { await controller.start('board-1') await vi.advanceTimersByTimeAsync(30000) - expect(fetchBoard).toHaveBeenCalledWith('board-1') + expect(fetchBoard).toHaveBeenCalledWith('board-1', { intent: 'background' }) await controller.stop() }) @@ -334,7 +428,7 @@ describe('createBoardRealtimeController', () => { await controller.setEditingCard('card-1') await callbacks.reconnecting?.() await vi.advanceTimersByTimeAsync(30000) - expect(fetchBoard).toHaveBeenCalledWith('board-1') + expect(fetchBoard).toHaveBeenCalledWith('board-1', { intent: 'background' }) fetchBoard.mockClear() await callbacks.reconnected?.() diff --git a/frontend/taskdeck-web/src/tests/composables/useKeyboardShortcuts.spec.ts b/frontend/taskdeck-web/src/tests/composables/useKeyboardShortcuts.spec.ts index 591a3bb59..e9e14b341 100644 --- a/frontend/taskdeck-web/src/tests/composables/useKeyboardShortcuts.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useKeyboardShortcuts.spec.ts @@ -116,6 +116,45 @@ describe('useKeyboardShortcuts', () => { wrapper.unmount() }) + it('leaves Enter on an interactive control to the control', () => { + const action = vi.fn() + const wrapper = mountWithShortcuts([ + { key: 'Enter', description: 'Open selected card', action }, + ]) + const button = document.createElement('button') + document.body.appendChild(button) + const event = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }) + const preventDefault = vi.spyOn(event, 'preventDefault') + + button.dispatchEvent(event) + + expect(action).not.toHaveBeenCalled() + expect(preventDefault).not.toHaveBeenCalled() + + button.remove() + wrapper.unmount() + }) + + it('keeps board navigation shortcuts available from a button', () => { + const action = vi.fn() + const wrapper = mountWithShortcuts([ + { key: 'ArrowRight', description: 'Next column', action }, + ]) + const button = document.createElement('button') + document.body.appendChild(button) + + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + + expect(action).toHaveBeenCalledTimes(1) + + button.remove() + wrapper.unmount() + }) + it('should allow Escape key even when typing in an input', () => { const action = vi.fn() const wrapper = mountWithShortcuts([ diff --git a/frontend/taskdeck-web/src/tests/composables/useRealtimeSafeDialogDraft.spec.ts b/frontend/taskdeck-web/src/tests/composables/useRealtimeSafeDialogDraft.spec.ts new file mode 100644 index 000000000..06cb5f2ad --- /dev/null +++ b/frontend/taskdeck-web/src/tests/composables/useRealtimeSafeDialogDraft.spec.ts @@ -0,0 +1,86 @@ +import { defineComponent, nextTick, ref } from 'vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { useRealtimeSafeDialogDraft } from '../../composables/useRealtimeSafeDialogDraft' + +type Source = { id: string; name: string; description: string } + +function mountHarness() { + const source = ref({ id: 'board-1', name: 'Board', description: 'Initial' }) + const isOpen = ref(false) + const isBusy = ref(false) + const name = ref('') + const description = ref('') + + mount(defineComponent({ + setup() { + useRealtimeSafeDialogDraft({ + isOpen: () => isOpen.value, + source: () => source.value, + sourceKey: (value) => value.id, + seed: (value) => { + name.value = value.name + description.value = value.description + }, + fields: [ + { + sourceValue: (value) => value.name, + draftValue: () => name.value, + apply: (value) => { name.value = value }, + }, + { + sourceValue: (value) => value.description, + draftValue: () => description.value, + apply: (value) => { description.value = value }, + }, + ], + isBusy: () => isBusy.value, + }) + return {} + }, + template: '', + })) + + return { source, isOpen, isBusy, name, description } +} + +describe('useRealtimeSafeDialogDraft', () => { + it('keeps an edited field dirty when realtime briefly matches its draft', async () => { + const state = mountHarness() + state.isOpen.value = true + await nextTick() + + state.name.value = 'Local name' + state.source.value = { id: 'board-1', name: 'Local name', description: 'First remote description' } + await nextTick() + expect(state.name.value).toBe('Local name') + expect(state.description.value).toBe('First remote description') + + state.source.value = { id: 'board-1', name: 'Later remote name', description: 'Later remote description' } + await nextTick() + expect(state.name.value).toBe('Local name') + expect(state.description.value).toBe('Later remote description') + }) + + it('resumes untouched-field reconciliation after a busy refresh is followed by a failed action', async () => { + const state = mountHarness() + state.isOpen.value = true + await nextTick() + + state.name.value = 'Local name' + state.source.value = { id: 'board-1', name: 'Remote name', description: 'Remote description' } + await nextTick() + expect(state.name.value).toBe('Local name') + expect(state.description.value).toBe('Remote description') + + state.isBusy.value = true + state.source.value = { id: 'board-1', name: 'Busy remote name', description: 'Busy remote description' } + await nextTick() + expect(state.description.value).toBe('Remote description') + + state.isBusy.value = false + await nextTick() + expect(state.name.value).toBe('Local name') + expect(state.description.value).toBe('Busy remote description') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts b/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts index 8be6dddfe..195f5b0aa 100644 --- a/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts +++ b/frontend/taskdeck-web/src/tests/resilience/degradedMode.spec.ts @@ -404,7 +404,7 @@ describe('useBoardRealtime — SignalR disconnect resilience', () => { // Reconnecting fires — fallback polling begins await realtimeCallbacks.reconnecting?.() await vi.advanceTimersByTimeAsync(30000) - expect(fetchBoard).toHaveBeenCalledWith('board-1') + expect(fetchBoard).toHaveBeenCalledWith('board-1', { intent: 'background' }) // Reconnected fires — fallback polling stops fetchBoard.mockClear() @@ -467,7 +467,7 @@ describe('useBoardRealtime — SignalR disconnect resilience', () => { // Advance past the 30s fallback poll interval await vi.advanceTimersByTimeAsync(30000) - expect(fetchBoard).toHaveBeenCalledWith('board-1') + expect(fetchBoard).toHaveBeenCalledWith('board-1', { intent: 'background' }) await controller.stop() }) diff --git a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts index 3f22e165e..385ffe591 100644 --- a/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/boardCrudStore.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { ref } from 'vue' import axios from 'axios' import { BOARD_REQUEST_TIMEOUT_MS } from '../../../api/http' +import { getErrorMessage } from '../../../utils/errorMessage' const { mockBoardsApi } = vi.hoisted(() => ({ mockBoardsApi: { @@ -70,11 +71,19 @@ function createMockState() { } function createMockHelpers(overrides: { isDemoMode?: boolean } = {}) { + const boardDetailMutationEpochs = new Map() + return { guardDemoMutation: vi.fn(), handleApiError: vi.fn(), isDemoMode: overrides.isDemoMode ?? false, toast: { success: vi.fn(), error: vi.fn() }, + getBoardDetailMutationEpoch: vi.fn( + (boardId: string) => boardDetailMutationEpochs.get(boardId) ?? 0, + ), + markBoardDetailMutation: vi.fn((boardId: string) => { + boardDetailMutationEpochs.set(boardId, (boardDetailMutationEpochs.get(boardId) ?? 0) + 1) + }), } } @@ -486,6 +495,208 @@ describe('boardCrudStore', () => { expect(state.error.value).toBeNull() expect(state.loading.value).toBe(false) }) + + it('queues and coalesces background refreshes behind an explicit board load', async () => { + const explicitBoard = createDeferred<{ id: string; name: string; columns: Array<{ id: string; cardCount: number }> }>() + const explicitCards = createDeferred>() + const explicitLabels = createDeferred>() + const backgroundBoard = createDeferred<{ id: string; name: string; columns: Array<{ id: string; cardCount: number }> }>() + const backgroundCards = createDeferred>() + const backgroundLabels = createDeferred>() + mockBoardsApi.getBoard + .mockReturnValueOnce(explicitBoard.promise) + .mockReturnValueOnce(backgroundBoard.promise) + mockCardsApi.getCards + .mockReturnValueOnce(explicitCards.promise) + .mockReturnValueOnce(backgroundCards.promise) + mockLabelsApi.getLabels + .mockReturnValueOnce(explicitLabels.promise) + .mockReturnValueOnce(backgroundLabels.promise) + + const { fetchBoard } = createBoardCrudActions(state as any, helpers as any) + const explicit = fetchBoard('board-1') + const queuedFirst = fetchBoard('board-1', { intent: 'background' }) + const queuedSecond = fetchBoard('board-1', { intent: 'background' }) + + expect(mockBoardsApi.getBoard).toHaveBeenCalledTimes(1) + expect(mockCardsApi.getCards).toHaveBeenCalledTimes(1) + expect(mockLabelsApi.getLabels).toHaveBeenCalledTimes(1) + + explicitBoard.resolve({ + id: 'board-1', + name: 'Recovered board', + columns: [{ id: 'column-1', cardCount: 0 }], + }) + explicitCards.resolve([{ id: 'card-explicit', columnId: 'column-1' }]) + explicitLabels.resolve([{ id: 'label-explicit', name: 'Explicit' }]) + await expect(explicit).resolves.toBe(true) + + expect(mockBoardsApi.getBoard).toHaveBeenCalledTimes(2) + expect(mockCardsApi.getCards).toHaveBeenCalledTimes(2) + expect(mockLabelsApi.getLabels).toHaveBeenCalledTimes(2) + + backgroundBoard.resolve({ + id: 'board-1', + name: 'Realtime board', + columns: [{ id: 'column-1', cardCount: 0 }], + }) + backgroundCards.resolve([{ id: 'card-background', columnId: 'column-1' }]) + backgroundLabels.resolve([{ id: 'label-background', name: 'Background' }]) + + await expect(queuedFirst).resolves.toBe(true) + await expect(queuedSecond).resolves.toBe(true) + expect(state.currentBoard.value).toMatchObject({ name: 'Realtime board' }) + expect(state.currentBoardCards.value).toEqual([ + { id: 'card-background', columnId: 'column-1' }, + ]) + }) + + it('keeps a recovered board and clean error state when its queued background refresh fails', async () => { + const explicitBoard = createDeferred<{ id: string; name: string; columns: Array<{ id: string; cardCount: number }> }>() + const explicitCards = createDeferred>() + const explicitLabels = createDeferred>() + mockBoardsApi.getBoard + .mockReturnValueOnce(explicitBoard.promise) + .mockRejectedValueOnce(new Error('background unavailable')) + mockCardsApi.getCards + .mockReturnValueOnce(explicitCards.promise) + .mockResolvedValueOnce([]) + mockLabelsApi.getLabels + .mockReturnValueOnce(explicitLabels.promise) + .mockResolvedValueOnce([]) + state.error.value = 'Previous board load failed' + + const { fetchBoard } = createBoardCrudActions(state as any, helpers as any) + const explicit = fetchBoard('board-1') + const queued = fetchBoard('board-1', { intent: 'background' }) + + explicitBoard.resolve({ id: 'board-1', name: 'Recovered board', columns: [] }) + explicitCards.resolve([{ id: 'card-recovered', columnId: 'column-1' }]) + explicitLabels.resolve([{ id: 'label-recovered', name: 'Recovered' }]) + + await expect(explicit).resolves.toBe(true) + await expect(queued).resolves.toBe(false) + expect(state.currentBoard.value).toMatchObject({ name: 'Recovered board' }) + expect(state.currentBoardCards.value).toEqual([ + { id: 'card-recovered', columnId: 'column-1' }, + ]) + expect(state.error.value).toBeNull() + expect(helpers.handleApiError).not.toHaveBeenCalled() + }) + + it('surfaces a current background 403 without replacing cached board state', async () => { + const forbidden = { + message: 'Request failed with status code 403', + response: { status: 403 }, + } + state.currentBoard.value = { id: 'board-1', name: 'Cached board' } + state.currentBoardCards.value = [{ id: 'cached-card' }] + state.currentBoardLabels.value = [{ id: 'cached-label' }] + state.cardCommentsByCardId.value = { 'cached-card': [{ text: 'Cached comment' }] } + mockBoardsApi.getBoard.mockRejectedValueOnce(forbidden) + mockCardsApi.getCards.mockResolvedValueOnce([]) + mockLabelsApi.getLabels.mockResolvedValueOnce([]) + helpers.handleApiError.mockImplementationOnce((error: unknown, fallback: string) => { + state.error.value = getErrorMessage(error, fallback) + }) + + const { fetchBoard } = createBoardCrudActions(state as any, helpers as any) + await expect(fetchBoard('board-1', { intent: 'background' })).resolves.toBe(false) + + expect(helpers.handleApiError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'You no longer have access to this board' }), + 'You no longer have access to this board', + ) + expect(state.error.value).toBe('You no longer have access to this board') + expect(state.currentBoard.value).toEqual({ id: 'board-1', name: 'Cached board' }) + expect(state.currentBoardCards.value).toEqual([{ id: 'cached-card' }]) + expect(state.currentBoardLabels.value).toEqual([{ id: 'cached-label' }]) + expect(state.cardCommentsByCardId.value).toEqual({ + 'cached-card': [{ text: 'Cached comment' }], + }) + }) + + it('suppresses a background 403 after a newer generation commits', async () => { + const staleBoard = createDeferred<{ id: string; name: string; columns: [] }>() + const forbidden = { + message: 'Request failed with status code 403', + response: { status: 403 }, + } + mockBoardsApi.getBoard + .mockReturnValueOnce(staleBoard.promise) + .mockResolvedValueOnce({ id: 'board-1', name: 'Current board', columns: [] }) + mockCardsApi.getCards.mockResolvedValue([]) + mockLabelsApi.getLabels.mockResolvedValue([]) + + const { fetchBoard } = createBoardCrudActions(state as any, helpers as any) + const stale = fetchBoard('board-1', { intent: 'background' }) + const current = fetchBoard('board-1') + + await expect(current).resolves.toBe(true) + staleBoard.reject(forbidden) + await expect(stale).resolves.toBe(false) + + expect(helpers.handleApiError).not.toHaveBeenCalled() + expect(state.currentBoard.value).toMatchObject({ name: 'Current board' }) + }) + + it('discards a queued background refresh when an explicit route load changes boards', async () => { + const boardA = createDeferred<{ id: string; name: string; columns: [] }>() + const cardsA = createDeferred>() + const labelsA = createDeferred>() + mockBoardsApi.getBoard + .mockReturnValueOnce(boardA.promise) + .mockResolvedValueOnce({ id: 'board-b', name: 'Board B', columns: [] }) + mockCardsApi.getCards + .mockReturnValueOnce(cardsA.promise) + .mockResolvedValueOnce([]) + mockLabelsApi.getLabels + .mockReturnValueOnce(labelsA.promise) + .mockResolvedValueOnce([]) + + const { fetchBoard } = createBoardCrudActions(state as any, helpers as any) + const first = fetchBoard('board-a') + const queued = fetchBoard('board-a', { intent: 'background' }) + + expect(mockBoardsApi.getBoard).toHaveBeenCalledTimes(1) + + const next = fetchBoard('board-b') + await expect(next).resolves.toBe(true) + await expect(queued).resolves.toBe(false) + + boardA.resolve({ id: 'board-a', name: 'Board A', columns: [] }) + cardsA.resolve([]) + labelsA.resolve([]) + await expect(first).resolves.toBe(false) + + expect(mockBoardsApi.getBoard).toHaveBeenCalledTimes(2) + expect(state.currentBoard.value).toMatchObject({ id: 'board-b' }) + }) + + it('discards queued background work when the board view unmounts', async () => { + const explicitBoard = createDeferred<{ id: string; name: string; columns: [] }>() + const explicitCards = createDeferred>() + const explicitLabels = createDeferred>() + mockBoardsApi.getBoard.mockReturnValueOnce(explicitBoard.promise) + mockCardsApi.getCards.mockReturnValueOnce(explicitCards.promise) + mockLabelsApi.getLabels.mockReturnValueOnce(explicitLabels.promise) + + const { fetchBoard, cancelBackgroundBoardFetch } = createBoardCrudActions( + state as any, + helpers as any, + ) + const explicit = fetchBoard('board-1') + const queued = fetchBoard('board-1', { intent: 'background' }) + + cancelBackgroundBoardFetch('board-1') + explicitBoard.resolve({ id: 'board-1', name: 'Recovered board', columns: [] }) + explicitCards.resolve([]) + explicitLabels.resolve([]) + + await expect(explicit).resolves.toBe(true) + await expect(queued).resolves.toBe(false) + expect(mockBoardsApi.getBoard).toHaveBeenCalledTimes(1) + }) }) describe('createBoard', () => { diff --git a/frontend/taskdeck-web/src/tests/store/board/cardStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/cardStore.spec.ts index 87f9731fb..43bbc7849 100644 --- a/frontend/taskdeck-web/src/tests/store/board/cardStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/cardStore.spec.ts @@ -79,6 +79,7 @@ function createMockHelpers() { isDemoMode: false, toast: { success: vi.fn(), error: vi.fn() }, updateColumnCardCount: vi.fn(), + markBoardDetailMutation: vi.fn(), } } @@ -166,6 +167,7 @@ describe('cardStore', () => { expect(result).toEqual(newCard) expect(state.currentBoardCards.value).toHaveLength(3) expect(state.currentBoardCards.value[2]).toEqual(newCard) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') expect(helpers.updateColumnCardCount).toHaveBeenCalledWith('col-1', 1) expect(helpers.toast.success).toHaveBeenCalledWith( 'Card "New Card" created successfully', @@ -214,6 +216,9 @@ describe('cardStore', () => { updatedAt: '2024-01-05T00:00:00Z', } mockCardsApi.updateCard.mockResolvedValueOnce(updatedCard) + helpers.markBoardDetailMutation.mockImplementationOnce(() => { + expect(state.currentBoardCards.value[0].title).toBe('First') + }) const { updateCard } = createCardActions(state as any, helpers as any) const result = await updateCard('board-1', 'card-1', { @@ -223,6 +228,7 @@ describe('cardStore', () => { expect(result).toEqual(updatedCard) expect(state.currentBoardCards.value[0]).toEqual(updatedCard) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') expect(helpers.toast.success).toHaveBeenCalledWith('Card updated successfully') expect(state.loading.value).toBe(false) }) @@ -302,6 +308,7 @@ describe('cardStore', () => { expect(state.currentBoardCards.value).toHaveLength(1) expect(state.currentBoardCards.value[0].id).toBe('card-2') + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') expect(state.cardCommentsByCardId.value).not.toHaveProperty('card-1') expect(helpers.updateColumnCardCount).toHaveBeenCalledWith('col-1', -1) expect(helpers.toast.success).toHaveBeenCalledWith('Card deleted successfully') @@ -368,6 +375,7 @@ describe('cardStore', () => { targetPosition: 0, }) expect(result).toEqual(movedCard) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') expect(state.currentBoardCards.value[state.currentBoardCards.value.length - 1]).toEqual( movedCard, ) diff --git a/frontend/taskdeck-web/src/tests/store/board/columnStore.spec.ts b/frontend/taskdeck-web/src/tests/store/board/columnStore.spec.ts index c97a11ea9..76da590ae 100644 --- a/frontend/taskdeck-web/src/tests/store/board/columnStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/board/columnStore.spec.ts @@ -40,6 +40,7 @@ function createMockHelpers() { guardDemoMutation: vi.fn(), handleApiError: vi.fn(), toast: { success: vi.fn(), error: vi.fn() }, + markBoardDetailMutation: vi.fn(), } } @@ -62,6 +63,7 @@ describe('columnStore', () => { expect(result).toEqual(newCol) expect(state.currentBoard.value!.columns).toHaveLength(3) expect(state.currentBoard.value!.columns[2]).toEqual(newCol) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') expect(helpers.toast.success).toHaveBeenCalled() expect(state.loading.value).toBe(false) }) @@ -153,10 +155,14 @@ describe('columnStore', () => { it('updates column in current board', async () => { const updated = { id: 'col-1', name: 'Backlog' } mockColumnsApi.updateColumn.mockResolvedValueOnce(updated) + helpers.markBoardDetailMutation.mockImplementationOnce(() => { + expect(state.currentBoard.value!.columns[0].name).toBe('Todo') + }) const { updateColumn } = createColumnActions(state as any, helpers as any) const result = await updateColumn('board-1', 'col-1', { name: 'Backlog' } as any) expect(result).toEqual(updated) expect(state.currentBoard.value!.columns[0]).toEqual(updated) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') expect(helpers.toast.success).toHaveBeenCalled() }) @@ -186,6 +192,7 @@ describe('columnStore', () => { expect(state.currentBoard.value!.columns[0].id).toBe('col-2') expect(state.currentBoardCards.value).toHaveLength(1) expect(state.currentBoardCards.value[0].id).toBe('card-2') + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') expect(helpers.toast.success).toHaveBeenCalled() }) @@ -208,6 +215,7 @@ describe('columnStore', () => { const result = await reorderColumns('board-1', ['col-2', 'col-1']) expect(result).toEqual(reordered) expect(state.currentBoard.value!.columns).toEqual(reordered) + expect(helpers.markBoardDetailMutation).toHaveBeenCalledWith('board-1') expect(helpers.toast.success).toHaveBeenCalled() }) diff --git a/frontend/taskdeck-web/src/tests/store/boardStore.spec.ts b/frontend/taskdeck-web/src/tests/store/boardStore.spec.ts index 0ebf245d6..77b373328 100644 --- a/frontend/taskdeck-web/src/tests/store/boardStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/boardStore.spec.ts @@ -6,9 +6,19 @@ import { cardsApi } from '../../api/cardsApi' import { cardCommentsApi } from '../../api/cardCommentsApi' import { columnsApi } from '../../api/columnsApi' import { labelsApi } from '../../api/labelsApi' -import type { Board, Card, Column, Label } from '../../types/board' +import type { Board, BoardDetail, Card, Column, Label } from '../../types/board' import type { CardComment } from '../../types/comments' +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve + reject = innerReject + }) + return { promise, resolve, reject } +} + // Mock all API modules vi.mock('../../api/boardsApi', () => ({ boardsApi: { @@ -110,6 +120,117 @@ describe('boardStore', () => { }) }) + describe('board detail read arbitration', () => { + const timestamp = '2026-09-03T10:00:00Z' + const column: Column = { + id: 'column-1', + boardId: 'board-1', + name: 'Todo', + position: 0, + wipLimit: null, + cardCount: 1, + createdAt: timestamp, + updatedAt: timestamp, + } + const loadedBoard: BoardDetail = { + id: 'board-1', + name: 'Loaded board', + description: null, + isArchived: false, + createdAt: timestamp, + updatedAt: timestamp, + columns: [column], + } + const localCard: Card = { + id: 'card-1', + boardId: 'board-1', + columnId: 'column-1', + title: 'Local card', + description: '', + dueDate: null, + isBlocked: false, + blockReason: null, + position: 0, + labels: [], + createdAt: timestamp, + updatedAt: timestamp, + } + + it('rejects a delayed fan-out whose cards resolved before a successful card write', async () => { + const boardRead = createDeferred() + const labelsRead = createDeferred() + const staleNetworkCard = { ...localCard, title: 'Stale network card' } + const updatedCard = { + ...localCard, + title: 'Locally updated card', + updatedAt: '2026-09-03T10:01:00Z', + } + store.currentBoard = structuredClone(loadedBoard) + store.currentBoardCards = [localCard] + vi.mocked(boardsApi.getBoard).mockReturnValueOnce(boardRead.promise) + vi.mocked(cardsApi.getCards).mockResolvedValueOnce([staleNetworkCard]) + vi.mocked(labelsApi.getLabels).mockReturnValueOnce(labelsRead.promise) + vi.mocked(cardsApi.updateCard).mockResolvedValueOnce(updatedCard) + + const delayedRead = store.fetchBoard('board-1') + await Promise.resolve() + await Promise.resolve() + + await store.updateCard('board-1', 'card-1', { + title: updatedCard.title, + description: updatedCard.description, + dueDate: updatedCard.dueDate, + isBlocked: updatedCard.isBlocked, + blockReason: updatedCard.blockReason, + }) + expect(store.currentBoardCards).toEqual([updatedCard]) + + boardRead.resolve({ ...structuredClone(loadedBoard), name: 'Delayed stale board' }) + labelsRead.resolve([]) + + await expect(delayedRead).resolves.toBe(false) + expect(store.currentBoardCards).toEqual([updatedCard]) + expect(store.currentBoard?.name).toBe('Loaded board') + }) + + it('rejects a delayed fan-out after a successful column write commits locally', async () => { + const boardRead = createDeferred() + const labelsRead = createDeferred() + const updatedColumn = { + ...column, + name: 'Doing', + updatedAt: '2026-09-03T10:02:00Z', + } + store.currentBoard = structuredClone(loadedBoard) + store.currentBoardCards = [localCard] + vi.mocked(boardsApi.getBoard).mockReturnValueOnce(boardRead.promise) + vi.mocked(cardsApi.getCards).mockResolvedValueOnce([localCard]) + vi.mocked(labelsApi.getLabels).mockReturnValueOnce(labelsRead.promise) + vi.mocked(columnsApi.updateColumn).mockResolvedValueOnce(updatedColumn) + + const delayedRead = store.fetchBoard('board-1') + await Promise.resolve() + await Promise.resolve() + + await store.updateColumn('board-1', 'column-1', { + name: updatedColumn.name, + wipLimit: updatedColumn.wipLimit, + }) + expect(store.currentBoard?.columns[0]).toEqual(updatedColumn) + + boardRead.resolve({ + ...structuredClone(loadedBoard), + name: 'Delayed stale board', + columns: [{ ...column, name: 'Stale column' }], + }) + labelsRead.resolve([]) + + await expect(delayedRead).resolves.toBe(false) + expect(store.currentBoard?.columns[0]).toEqual(updatedColumn) + expect(store.currentBoard?.name).toBe('Loaded board') + }) + }) + describe('createBoard', () => { it('should create a new board and add it to the store', async () => { const newBoard: Board = { diff --git a/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts b/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts index fda7524f7..616441788 100644 --- a/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts @@ -1,11 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { captureApi } from '../../api/captureApi' -import { useCaptureStore } from '../../store/captureStore' +import { + BATCH_TRIAGE_POLL_MAX_DURATION_MS, + useCaptureStore, +} from '../../store/captureStore' const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), + warning: vi.fn(), })) const workspaceMocks = vi.hoisted(() => ({ @@ -1597,6 +1601,7 @@ describe('captureStore', () => { await vi.advanceTimersByTimeAsync(9_000) expect(captureApi.listItems).toHaveBeenCalledTimes(2) + expect(toastMocks.error).not.toHaveBeenCalled() }) it('retries a terminal detail reconciliation after a transient detail failure', async () => { @@ -1812,22 +1817,68 @@ describe('captureStore', () => { expect(captureApi.listItems).toHaveBeenCalledTimes(1) }) - it('aborts and stops at the 60-second deadline', async () => { + it('aborts and reports one persistent receipt at the 60-second deadline when tracked ids remain unresolved', async () => { vi.useFakeTimers() - const store = useCaptureStore() - vi.mocked(captureApi.listItems).mockResolvedValue([ - { id: 'c-live', userId: 'u1', boardId: null, status: 'Triaging', source: 'Typed', - textExcerpt: 'still working', createdAt: new Date().toISOString(), processedAt: null } as never, - ]) + try { + const store = useCaptureStore() + const trackedIds = Array.from({ length: 50 }, (_, index) => `c-live-${index}`) + vi.mocked(captureApi.listItems).mockResolvedValue( + trackedIds.map((id) => ({ + id, + userId: 'u1', + boardId: null, + status: 'Triaging', + source: 'Typed', + textExcerpt: 'still working', + createdAt: new Date().toISOString(), + processedAt: null, + })) as never, + ) - store.pollBatchTriageCompletion(['c-live']) - await vi.advanceTimersByTimeAsync(60_000) - const callsAtDeadline = vi.mocked(captureApi.listItems).mock.calls.length + store.pollBatchTriageCompletion(trackedIds) + await vi.advanceTimersByTimeAsync(BATCH_TRIAGE_POLL_MAX_DURATION_MS) - expect(callsAtDeadline).toBeGreaterThan(0) - expect(callsAtDeadline).toBeLessThanOrEqual(20) - await vi.advanceTimersByTimeAsync(9_000) - expect(captureApi.listItems).toHaveBeenCalledTimes(callsAtDeadline) + const callsAtDeadline = vi.mocked(captureApi.listItems).mock.calls.length + expect(callsAtDeadline).toBeGreaterThan(0) + expect(callsAtDeadline).toBeLessThanOrEqual(20) + expect(store.batchError).toBe( + 'Automatic checking stopped after 60 seconds. Triage may still be running. Use Refresh Detail to check the result.', + ) + expect(toastMocks.warning).toHaveBeenCalledTimes(1) + expect(toastMocks.warning).toHaveBeenCalledWith( + 'Automatic checking stopped after 60 seconds. Triage may still be running. Use Refresh Detail to check the result.', + 0, + ) + expect(toastMocks.error).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(9_000) + expect(captureApi.listItems).toHaveBeenCalledTimes(callsAtDeadline) + expect(toastMocks.warning).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('keeps an explicit stop quiet before the deadline', async () => { + vi.useFakeTimers() + try { + const store = useCaptureStore() + vi.mocked(captureApi.listItems).mockResolvedValue([ + { id: 'c-live', userId: 'u1', boardId: null, status: 'Triaging', source: 'Typed', + textExcerpt: 'still working', createdAt: new Date().toISOString(), processedAt: null } as never, + ]) + + const stop = store.pollBatchTriageCompletion(['c-live']) + stop() + await vi.advanceTimersByTimeAsync(BATCH_TRIAGE_POLL_MAX_DURATION_MS) + + expect(store.batchError).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + expect(toastMocks.warning).not.toHaveBeenCalled() + expect(captureApi.listItems).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } }) }) diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts index 0128095bd..47e8f012d 100644 --- a/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/BoardView.keyboardRouting.spec.ts @@ -121,6 +121,12 @@ const BoardCanvasStub = { template: '
', } +const PaperBoardViewStub = { + props: ['selectedColumnId'], + template: + '
', +} + function mountView() { const wrapper = mount(BoardView, { attachTo: document.body, @@ -133,6 +139,7 @@ function mountView() { KeyboardShortcutsHelp: { template: '
' }, FilterPanel: { template: '
' }, CaptureModal: { template: '
' }, + PaperBoardView: PaperBoardViewStub, }, }, }) @@ -196,4 +203,18 @@ describe('BoardView keyboard routing', () => { // BoardView re-bound it the selection would slide back to card-1 here. expect(selectedCardId()).toBe('card-2') }) + + it('passes the live keyboard lane selection to the Paper board', async () => { + usePaperThemeStore().enable() + mountView() + await waitForUi() + + const paperBoard = () => document.body.querySelector('[data-testid="paper-board"]') + expect(paperBoard()?.getAttribute('data-selected-column-id')).toBe('column-1') + + pressKey('ArrowRight') + await waitForUi() + + expect(paperBoard()?.getAttribute('data-selected-column-id')).toBe('column-2') + }) }) diff --git a/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts b/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts index 4b23e5f3e..d4dd00d17 100644 --- a/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/BoardView.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' +import { flushPromises, mount } from '@vue/test-utils' import { nextTick, reactive } from 'vue' import BoardView from '../../views/BoardView.vue' import { useKeyboardShortcuts } from '../../composables/useKeyboardShortcuts' @@ -54,7 +54,9 @@ const realtimeMock = { // Captures the onPresenceChanged callback passed by BoardView so tests can // simulate incoming SignalR presence snapshots. let capturedOnPresenceChanged: ((snapshot: BoardPresenceSnapshot) => void) | undefined -let capturedRealtimeFetchBoard: ((boardId: string) => Promise) | undefined +let capturedRealtimeFetchBoard: + | ((boardId: string, options: { intent: 'background' }) => Promise) + | undefined const mockBoardStore = reactive({ currentBoard: { @@ -92,6 +94,7 @@ const mockBoardStore = reactive({ filteredCardCount: 0, totalCardCount: 0, fetchBoard: vi.fn(async () => true), + cancelBackgroundBoardFetch: vi.fn(), setBoardPresenceMembers: vi.fn(), setEditingCard: vi.fn(), createColumn: vi.fn(async () => {}), @@ -203,6 +206,8 @@ describe('BoardView', () => { mockBoardStore.currentBoardCards = [] mockBoardStore.loading = false mockBoardStore.error = null + mockBoardStore.fetchBoard.mockReset() + mockBoardStore.fetchBoard.mockResolvedValue(true) addCardToggleMock.mockReset() usePaperThemeStore().disable() }) @@ -499,6 +504,242 @@ describe('BoardView', () => { expect(firstCall[0]).toEqual([]) }) + it('retries an initial board-load failure once, disables duplicate retries, and starts realtime after recovery', async () => { + const loadedBoard = mockBoardStore.currentBoard! + const retryLoad = createDeferred() + mockBoardStore.currentBoard = null as unknown as typeof mockBoardStore.currentBoard + mockBoardStore.fetchBoard + .mockImplementationOnce(async () => { + mockBoardStore.error = 'Network Error' + throw new Error('offline') + }) + .mockImplementationOnce(() => { + mockBoardStore.loading = true + mockBoardStore.error = null + return retryLoad.promise + }) + + const wrapper = mountView() + await flushPromises() + + const errorAlert = wrapper.get('[data-testid="board-load-error"]') + expect(errorAlert.attributes('role')).toBe('alert') + expect(errorAlert.text()).toContain("We couldn't load this board") + expect(errorAlert.text()).toContain('Network Error') + + const retry = errorAlert.get('[data-testid="board-load-retry"]') + expect(retry.attributes('disabled')).toBeUndefined() + await retry.trigger('click') + await nextTick() + + expect(mockBoardStore.fetchBoard).toHaveBeenCalledTimes(2) + expect(mockBoardStore.fetchBoard).toHaveBeenLastCalledWith('board-1') + expect(wrapper.get('[data-testid="board-load-retry"]').attributes('disabled')).toBeDefined() + expect(wrapper.get('[data-testid="board-load-error"]').text()).toContain('Network Error') + + await wrapper.get('[data-testid="board-load-retry"]').trigger('click') + expect(mockBoardStore.fetchBoard).toHaveBeenCalledTimes(2) + + mockBoardStore.currentBoard = loadedBoard + mockBoardStore.loading = false + retryLoad.resolve(true) + await flushPromises() + + expect(realtimeMock.start).toHaveBeenCalledTimes(1) + expect(realtimeMock.start).toHaveBeenCalledWith('board-1') + expect(wrapper.find('[data-testid="board-load-error"]').exists()).toBe(false) + expect(wrapper.find('.td-board-canvas').exists()).toBe(true) + }) + + it('keeps Retry authoritative while realtime refreshes queue as background work', async () => { + const retryLoad = createDeferred() + mockBoardStore.currentBoard = null as unknown as typeof mockBoardStore.currentBoard + mockBoardStore.fetchBoard + .mockImplementationOnce(async () => { + mockBoardStore.error = 'Network Error' + throw new Error('offline') + }) + .mockImplementationOnce(() => retryLoad.promise) + .mockResolvedValueOnce(false) + + const wrapper = mountView() + await flushPromises() + + // A concurrent card or column mutation may own the legacy shared loading + // flag. It must not prevent the board-specific Retry from starting. + mockBoardStore.loading = true + await nextTick() + const retry = wrapper.get('[data-testid="board-load-retry"]') + expect(retry.attributes('disabled')).toBeUndefined() + + await retry.trigger('click') + await nextTick() + expect(mockBoardStore.fetchBoard).toHaveBeenCalledTimes(2) + + expect(capturedRealtimeFetchBoard).toBeDefined() + await capturedRealtimeFetchBoard!('board-1', { intent: 'background' }) + expect(mockBoardStore.fetchBoard).toHaveBeenNthCalledWith(3, 'board-1', { + intent: 'background', + }) + + wrapper.unmount() + expect(mockBoardStore.cancelBackgroundBoardFetch).toHaveBeenCalledWith('board-1') + + retryLoad.resolve(true) + await flushPromises() + }) + + it('does not restart realtime when a pending Retry resolves after unmount', async () => { + const retryLoad = createDeferred() + mockBoardStore.currentBoard = null as unknown as typeof mockBoardStore.currentBoard + mockBoardStore.fetchBoard + .mockImplementationOnce(async () => { + mockBoardStore.error = 'Network Error' + throw new Error('offline') + }) + .mockImplementationOnce(() => retryLoad.promise) + + const wrapper = mountView() + await flushPromises() + await wrapper.get('[data-testid="board-load-retry"]').trigger('click') + await nextTick() + + wrapper.unmount() + retryLoad.resolve(true) + await flushPromises() + + expect(realtimeMock.stop).toHaveBeenCalledTimes(1) + expect(realtimeMock.start).not.toHaveBeenCalled() + expect(realtimeMock.switchBoard).not.toHaveBeenCalled() + }) + + it('does not start realtime when the initial board load resolves after unmount', async () => { + const initialLoad = createDeferred() + mockBoardStore.fetchBoard.mockImplementationOnce(() => initialLoad.promise) + + const wrapper = mountView() + await nextTick() + wrapper.unmount() + + initialLoad.resolve(true) + await flushPromises() + + expect(realtimeMock.stop).toHaveBeenCalledTimes(1) + expect(realtimeMock.start).not.toHaveBeenCalled() + expect(realtimeMock.switchBoard).not.toHaveBeenCalled() + }) + + it('keeps the loaded Legacy board canvas mounted when an explicit retry also fails', async () => { + const retryLoad = createDeferred() + mockBoardStore.fetchBoard + .mockImplementationOnce(async () => { + mockBoardStore.error = 'Board refresh failed' + throw new Error('offline') + }) + .mockImplementationOnce(() => { + mockBoardStore.loading = true + mockBoardStore.error = null + return retryLoad.promise + }) + + const wrapper = mountView() + await flushPromises() + + expect(wrapper.find('.td-board-canvas').exists()).toBe(true) + expect(wrapper.get('[data-testid="board-load-error"]').text()).toContain( + 'Your last loaded board is still shown', + ) + + await wrapper.get('[data-testid="board-load-retry"]').trigger('click') + await nextTick() + expect(wrapper.find('.td-board-canvas').exists()).toBe(true) + + mockBoardStore.loading = false + mockBoardStore.error = 'Board refresh still unavailable' + retryLoad.reject(new Error('still offline')) + await flushPromises() + + expect(wrapper.find('.td-board-canvas').exists()).toBe(true) + expect(wrapper.get('[data-testid="board-load-error"]').text()).toContain( + 'Board refresh still unavailable', + ) + }) + + it('hides a cached board when the newly routed board fails to load', async () => { + mockBoardStore.fetchBoard + .mockResolvedValueOnce(true) + .mockImplementationOnce(async () => { + mockBoardStore.loading = false + mockBoardStore.error = 'Board B unavailable' + throw new Error('board B unavailable') + }) + + const wrapper = mountView() + await waitForUi() + + routeMock.params.id = 'board-2' + await nextTick() + await flushPromises() + + const errorAlert = wrapper.get('[data-testid="board-load-error"]') + expect(errorAlert.text()).toContain("We couldn't load this board") + expect(errorAlert.text()).not.toContain('Your last loaded board is still shown') + expect(wrapper.find('.td-board-canvas').exists()).toBe(false) + expect(wrapper.find('[data-board-action-rail]').exists()).toBe(false) + }) + + it('reports mutation errors without presenting them as retryable board-load failures', async () => { + const wrapper = mountView() + await flushPromises() + + mockBoardStore.error = 'Failed to create card' + await nextTick() + + expect(wrapper.get('[data-testid="board-error"]').text()).toBe('Failed to create card') + expect(wrapper.find('[data-testid="board-load-error"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="board-load-retry"]').exists()).toBe(false) + expect(wrapper.find('.td-board-canvas').exists()).toBe(true) + }) + + it('does not let an old board Retry replace a newer route load error', async () => { + const oldBoardRetry = createDeferred() + mockBoardStore.currentBoard = null as unknown as typeof mockBoardStore.currentBoard + mockBoardStore.fetchBoard + .mockImplementationOnce(async () => { + mockBoardStore.error = 'Board A unavailable' + throw new Error('board A unavailable') + }) + .mockImplementationOnce(() => { + mockBoardStore.loading = true + mockBoardStore.error = null + return oldBoardRetry.promise + }) + .mockImplementationOnce(async () => { + mockBoardStore.loading = false + mockBoardStore.error = 'Board B unavailable' + throw new Error('board B unavailable') + }) + + const wrapper = mountView() + await flushPromises() + await wrapper.get('[data-testid="board-load-retry"]').trigger('click') + + routeMock.params.id = 'board-2' + await nextTick() + await flushPromises() + expect(wrapper.get('[data-testid="board-load-error"]').text()).toContain('Board B unavailable') + + mockBoardStore.error = 'Stale Board A retry failure' + oldBoardRetry.reject(new Error('stale retry failed')) + await flushPromises() + + expect(wrapper.get('[data-testid="board-load-error"]').text()).toContain('Board B unavailable') + expect(wrapper.get('[data-testid="board-load-error"]').text()).not.toContain( + 'Stale Board A retry failure', + ) + expect(realtimeMock.start).not.toHaveBeenCalled() + }) + it('switches realtime only for the newest board load when A resolves after B', async () => { const firstLoad = createDeferred() const secondLoad = createDeferred() @@ -542,7 +783,7 @@ describe('BoardView', () => { expect(mockBoardStore.fetchBoard).toHaveBeenNthCalledWith(2, 'board-2') expect(capturedRealtimeFetchBoard).toBeDefined() - await capturedRealtimeFetchBoard!('board-1') + await capturedRealtimeFetchBoard!('board-1', { intent: 'background' }) expect(mockBoardStore.fetchBoard).toHaveBeenCalledTimes(2) boardBLoad.resolve(true) diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardDialogShell.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardDialogShell.spec.ts new file mode 100644 index 000000000..fb889115a --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardDialogShell.spec.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import PaperBoardDialogShell from '../../../views/paper/board/PaperBoardDialogShell.vue' + +const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport') + +function installVisualViewport(height: number, offsetTop: number) { + const events = new EventTarget() + let currentHeight = height + let currentOffsetTop = offsetTop + + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { + get height() { + return currentHeight + }, + get offsetTop() { + return currentOffsetTop + }, + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + }, + }) + + return (next: { height: number; offsetTop: number }) => { + currentHeight = next.height + currentOffsetTop = next.offsetTop + events.dispatchEvent(new Event('resize')) + events.dispatchEvent(new Event('scroll')) + } +} + +function mountShell() { + return mount(PaperBoardDialogShell, { + attachTo: document.body, + props: { + isOpen: true, + eyebrow: 'Board', + title: 'Board settings', + closeLabel: 'Close settings', + testid: 'paper-dialog-shell', + }, + slots: { + default: '', + footer: '', + }, + }) +} + +afterEach(() => { + document.body.innerHTML = '' + if (originalVisualViewport) { + Object.defineProperty(window, 'visualViewport', originalVisualViewport) + } else { + Reflect.deleteProperty(window, 'visualViewport') + } +}) + +describe('PaperBoardDialogShell', () => { + it('traps forward and backward Tab movement inside the dialog', async () => { + const wrapper = mountShell() + await nextTick() + + const close = wrapper.get('[data-action="close-dialog"]').element as HTMLElement + const save = wrapper.get('[data-testid="dialog-save"]').element as HTMLElement + + save.focus() + await wrapper.get('[data-testid="dialog-save"]').trigger('keydown', { key: 'Tab' }) + expect(document.activeElement).toBe(close) + + await wrapper.get('[data-action="close-dialog"]').trigger('keydown', { + key: 'Tab', + shiftKey: true, + }) + expect(document.activeElement).toBe(save) + + wrapper.unmount() + }) + + it('follows the contracted visual viewport while open', async () => { + const resizeViewport = installVisualViewport(760, 0) + const wrapper = mountShell() + const backdrop = wrapper.get('[data-testid="paper-dialog-shell"]').element as HTMLElement + + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-height'), + ).toBe('760px') + + resizeViewport({ height: 420, offsetTop: 120 }) + await nextTick() + + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-height'), + ).toBe('420px') + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-offset-top'), + ).toBe('120px') + + wrapper.unmount() + }) + + it('keeps the dynamic-viewport CSS fallback when VisualViewport is unavailable', () => { + Object.defineProperty(window, 'visualViewport', { configurable: true, value: undefined }) + const wrapper = mountShell() + const backdrop = wrapper.get('[data-testid="paper-dialog-shell"]').element as HTMLElement + + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-height'), + ).toBe('') + expect( + backdrop.style.getPropertyValue('--paper-board-dialog-visual-viewport-offset-top'), + ).toBe('') + + wrapper.unmount() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts index 53ad3e37d..1abe91739 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardManagement.spec.ts @@ -288,6 +288,70 @@ describe('PaperBoardView — column settings', () => { ).toBe('Today') }) + it('keeps a column draft when realtime replaces the live column object', async () => { + const wrapper = mountView() + + await wrapper.findAll('[data-testid="paper-column-edit"]')[1]!.trigger('click') + await wrapper.get('[data-testid="paper-column-dialog-name"]').setValue('My draft column') + + mockBoardStore.currentBoard = { + ...board, + columns: columns.map((column) => + column.id === 'col-today' + ? { ...column, name: 'Server refresh', updatedAt: new Date().toISOString() } + : column, + ), + } + await nextTick() + + expect( + (wrapper.get('[data-testid="paper-column-dialog-name"]').element as HTMLInputElement).value, + ).toBe('My draft column') + }) + + it('reconciles an untouched column field while preserving a dirty Paper draft', async () => { + const wrapper = mountView() + + await wrapper.findAll('[data-testid="paper-column-edit"]')[1]!.trigger('click') + await wrapper.get('[data-testid="paper-column-dialog-name"]').setValue('My draft column') + + mockBoardStore.currentBoard = { + ...board, + columns: columns.map((column) => + column.id === 'col-today' ? { ...column, name: 'Server column', wipLimit: 3 } : column, + ), + } + await nextTick() + + expect((wrapper.get('[data-testid="paper-column-dialog-name"]').element as HTMLInputElement).value) + .toBe('My draft column') + expect((wrapper.get('[data-testid="paper-column-dialog-wip-toggle"]').element as HTMLInputElement).checked) + .toBe(true) + expect((wrapper.get('[data-testid="paper-column-dialog-wip"]').element as HTMLInputElement).value) + .toBe('3') + }) + + it('keeps a locally edited WIP pair when realtime clears the remote limit', async () => { + const wrapper = mountView() + + await wrapper.findAll('[data-testid="paper-column-edit"]')[0]!.trigger('click') + await wrapper.get('[data-testid="paper-column-dialog-wip-toggle"]').setValue(true) + await wrapper.get('[data-testid="paper-column-dialog-wip"]').setValue(5) + + mockBoardStore.currentBoard = { + ...board, + columns: columns.map((column) => + column.id === 'col-backlog' ? { ...column, wipLimit: null } : column, + ), + } + await nextTick() + + expect((wrapper.get('[data-testid="paper-column-dialog-wip-toggle"]').element as HTMLInputElement).checked) + .toBe(true) + expect((wrapper.get('[data-testid="paper-column-dialog-wip"]').element as HTMLInputElement).value) + .toBe('5') + }) + it('renames a column through boardStore.updateColumn and closes', async () => { const wrapper = mountView() @@ -348,6 +412,42 @@ describe('PaperBoardView — column settings', () => { expect(mockBoardStore.deleteColumn).not.toHaveBeenCalled() }) + it('reconciles an untouched column field when delete confirmation is cancelled without another refresh', async () => { + const wrapper = mountView() + + await wrapper.findAll('[data-testid="paper-column-edit"]')[1]!.trigger('click') + await wrapper.get('[data-testid="paper-column-dialog-name"]').setValue('My draft column') + await wrapper.get('[data-testid="paper-column-dialog-delete"]').trigger('click') + + mockBoardStore.currentBoard = { + ...board, + columns: columns.map((column) => + column.id === 'col-today' + ? { ...column, name: 'Busy server column', wipLimit: 3 } + : column, + ), + } + await nextTick() + await wrapper.get('[data-testid="paper-column-dialog-delete-confirm-no"]').trigger('click') + + expect((wrapper.get('[data-testid="paper-column-dialog-name"]').element as HTMLInputElement).value) + .toBe('My draft column') + expect((wrapper.get('[data-testid="paper-column-dialog-wip-toggle"]').element as HTMLInputElement).checked) + .toBe(true) + expect((wrapper.get('[data-testid="paper-column-dialog-wip"]').element as HTMLInputElement).value) + .toBe('3') + + mockBoardStore.updateColumn.mockClear() + await wrapper.get('[data-testid="paper-column-dialog-save"]').trigger('click') + await flushPromises() + + expect(mockBoardStore.updateColumn).toHaveBeenCalledWith('board-1', 'col-today', { + name: 'My draft column', + wipLimit: 3, + position: null, + }) + }) + it('refuses to delete a column that still holds cards, and says why', async () => { const wrapper = mountView() @@ -414,6 +514,50 @@ describe('PaperBoardView — column reorder', () => { expect(right[2]?.attributes('disabled')).toBeDefined() expect(right[0]?.attributes('disabled')).toBeUndefined() }) + + it('allows only one column reorder while the first request is in flight', async () => { + let resolveReorder!: () => void + mockBoardStore.reorderColumns.mockReturnValueOnce( + new Promise((resolve) => { + resolveReorder = () => resolve(columns) + }), + ) + const wrapper = mountView() + const moveRight = wrapper.findAll('[data-testid="paper-column-move-right"]')[0]! + + void moveRight.trigger('click') + await nextTick() + await moveRight.trigger('click') + + expect(mockBoardStore.reorderColumns).toHaveBeenCalledTimes(1) + expect(moveRight.attributes('disabled')).toBeDefined() + + resolveReorder() + await flushPromises() + expect(moveRight.attributes('disabled')).toBeUndefined() + }) + + it('ignores a column drop while a move-button reorder is in flight', async () => { + let resolveReorder!: () => void + mockBoardStore.reorderColumns.mockReturnValueOnce( + new Promise((resolve) => { + resolveReorder = () => resolve(columns) + }), + ) + const wrapper = mountView() + + void wrapper.findAll('[data-testid="paper-column-move-right"]')[0]!.trigger('click') + await nextTick() + + const lanes = wrapper.findAll('[data-column-dnd-id]') + await lanes[1]!.get('[data-action="drag-column-handle"]').trigger('dragstart') + await lanes[2]!.trigger('drop') + + expect(mockBoardStore.reorderColumns).toHaveBeenCalledTimes(1) + + resolveReorder() + await flushPromises() + }) }) describe('PaperBoardView — add a column to a populated board', () => { @@ -513,6 +657,113 @@ describe('PaperBoardView — board settings', () => { expect(wrapper.get('[data-testid="paper-board-dialog-state"]').text()).toBe('Active') }) + it('keeps board drafts when realtime replaces the live board object', async () => { + const wrapper = mountView() + + await wrapper.get('[data-testid="paper-board-settings"]').trigger('click') + await wrapper.get('[data-testid="paper-board-dialog-name"]').setValue('My draft board') + await wrapper + .get('[data-testid="paper-board-dialog-description"]') + .setValue('My draft description') + + mockBoardStore.currentBoard = { + ...board, + name: 'Server refresh', + description: 'Server description', + updatedAt: new Date().toISOString(), + } + await nextTick() + + expect( + (wrapper.get('[data-testid="paper-board-dialog-name"]').element as HTMLInputElement).value, + ).toBe('My draft board') + expect( + ( + wrapper.get('[data-testid="paper-board-dialog-description"]') + .element as HTMLTextAreaElement + ).value, + ).toBe('My draft description') + }) + + it('saves a dirty Paper field after realtime briefly matches it and keeps its sibling current', async () => { + const wrapper = mountView() + + await wrapper.get('[data-testid="paper-board-settings"]').trigger('click') + await wrapper.get('[data-testid="paper-board-dialog-name"]').setValue('My draft board') + + mockBoardStore.currentBoard = { + ...board, + name: 'My draft board', + description: 'First server description', + } + await nextTick() + + expect((wrapper.get('[data-testid="paper-board-dialog-name"]').element as HTMLInputElement).value) + .toBe('My draft board') + expect( + (wrapper.get('[data-testid="paper-board-dialog-description"]').element as HTMLTextAreaElement) + .value, + ).toBe('First server description') + + mockBoardStore.currentBoard = { + ...board, + name: 'Later server board', + description: 'Latest server description', + } + await nextTick() + + expect((wrapper.get('[data-testid="paper-board-dialog-name"]').element as HTMLInputElement).value) + .toBe('My draft board') + expect( + (wrapper.get('[data-testid="paper-board-dialog-description"]').element as HTMLTextAreaElement) + .value, + ).toBe('Latest server description') + + mockBoardStore.updateBoard.mockClear() + await wrapper.get('[data-testid="paper-board-dialog-save"]').trigger('click') + await flushPromises() + + expect(mockBoardStore.updateBoard).toHaveBeenCalledWith('board-1', { + name: 'My draft board', + description: null, + isArchived: null, + }) + }) + + it('reconciles an untouched board field when archive confirmation is cancelled without another refresh', async () => { + const wrapper = mountView() + + await wrapper.get('[data-testid="paper-board-settings"]').trigger('click') + await wrapper.get('[data-testid="paper-board-dialog-name"]').setValue('My draft board') + await wrapper.get('[data-testid="paper-board-dialog-archive"]').trigger('click') + + mockBoardStore.currentBoard = { + ...board, + name: 'Busy server name', + description: 'Busy server description', + } + await nextTick() + await wrapper.get('[data-testid="paper-board-dialog-archive-confirm-no"]').trigger('click') + + expect( + (wrapper.get('[data-testid="paper-board-dialog-name"]').element as HTMLInputElement).value, + ).toBe('My draft board') + expect( + (wrapper.get('[data-testid="paper-board-dialog-description"]').element as HTMLTextAreaElement) + .value, + ).toBe('Busy server description') + + mockBoardStore.updateBoard.mockClear() + await wrapper.get('[data-testid="paper-board-dialog-save"]').trigger('click') + await flushPromises() + + expect(mockBoardStore.updateBoard).toHaveBeenCalledWith('board-1', { + name: 'My draft board', + description: null, + isArchived: null, + }) + }) + it('renames the board through boardStore.updateBoard and closes', async () => { const wrapper = mountView() @@ -646,6 +897,17 @@ describe('PaperBoardView — dialogs and the board shortcuts', () => { }) }) +describe('PaperBoardView — visible keyboard column selection', () => { + it('marks the lane targeted by the board keyboard model', () => { + const wrapper = mountView({ selectedColumnId: 'col-today' }) + const selected = wrapper.get('[data-column-id="col-today"]') + + expect(selected.classes()).toContain('paper-board-column--selected') + expect(selected.attributes('aria-current')).toBe('true') + expect(wrapper.get('[data-column-id="col-backlog"]').attributes('aria-current')).toBeUndefined() + }) +}) + describe('PaperBoardView — the capture lane still exists', () => { it('routes "+ capture" to the column-scoped Inbox composer', async () => { const wrapper = mountView() diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardView.spec.ts index b9baf36c8..61e8ca363 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperBoardView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperBoardView.spec.ts @@ -626,6 +626,18 @@ describe('PaperBoardView', () => { expect(dragStart.defaultPrevented).toBe(false) }) + it('cancels a drop when no column drag is active', async () => { + const wrapper = mountView() + const targetLane = wrapper.get('[data-column-dnd-id="col-today"]') + const drop = makeDragEvent('drop') + + targetLane.element.dispatchEvent(drop) + await nextTick() + + expect(drop.defaultPrevented).toBe(true) + expect(mockBoardStore.reorderColumns).not.toHaveBeenCalled() + }) + it('keeps a collapsed target lane as a coherent card drop surface', async () => { window.localStorage.setItem('td.paper.board-collapsed-columns.v1', JSON.stringify(['col-today'])) const wrapper = mountView() @@ -741,6 +753,7 @@ describe('PaperBoardView — empty board (#1765)', () => { beforeEach(() => { routerMock.push.mockClear() mockBoardStore.createColumn.mockClear() + routeMock.params.id = 'board-1' mockBoardStore.currentBoard = emptyBoard mockBoardStore.currentBoardCards = [] mockBoardStore.cardsByColumn = new Map() @@ -828,13 +841,93 @@ describe('PaperBoardView — empty board (#1765)', () => { ) }) - it('still shows the board error banner when the board itself failed to load', () => { + it('shows a specific accessible load alert and emits an explicit Retry request', async () => { mockBoardStore.currentBoard = null mockBoardStore.error = 'Board not found' - const wrapper = mountView() + const wrapper = mountView({ boardLoadError: 'Board not found' }) + + const alert = wrapper.get('[data-testid="paper-board-load-error"]') + expect(alert.attributes('role')).toBe('alert') + expect(alert.text()).toContain("We couldn't load this board") + expect(alert.text()).toContain('Board not found') + const retry = alert.get('[data-testid="paper-board-load-retry"]') + expect(retry.attributes('disabled')).toBeUndefined() - expect(wrapper.get('.paper-board-view__error').text()).toBe('Board not found') + await retry.trigger('click') + expect(wrapper.emitted('retry-board-load')).toHaveLength(1) + + mockBoardStore.error = null + await wrapper.setProps({ boardLoadRetrying: true }) + expect(wrapper.get('[data-testid="paper-board-load-error"]').text()).toContain('Board not found') + expect(wrapper.get('[data-testid="paper-board-load-retry"]').attributes('disabled')).toBeDefined() expect(wrapper.find('[data-testid="paper-board-empty"]').exists()).toBe(false) }) + + it('keeps loaded Paper lanes visible beside a board refresh error', () => { + mockBoardStore.currentBoard = board + mockBoardStore.currentBoardCards = allCards + mockBoardStore.cardsByColumn = cardsByColumn + mockBoardStore.error = 'Board refresh failed' + + const wrapper = mountView({ boardLoadError: 'Board refresh failed' }) + + expect(wrapper.get('[data-testid="paper-board-load-error"]').text()).toContain( + 'Your last loaded board is still shown', + ) + expect(wrapper.find('[data-testid="paper-board-workspace"]').exists()).toBe(true) + }) + + it('hides cached Paper lanes when they belong to a different routed board', () => { + routeMock.params.id = 'board-2' + mockBoardStore.currentBoard = board + mockBoardStore.currentBoardCards = allCards + mockBoardStore.cardsByColumn = cardsByColumn + mockBoardStore.error = 'Board B unavailable' + + const wrapper = mountView({ boardLoadError: 'Board B unavailable' }) + + expect(wrapper.get('[data-testid="paper-board-load-error"]').text()).toContain( + "We couldn't load this board", + ) + expect(wrapper.get('[data-testid="paper-board-load-error"]').text()).not.toContain( + 'Your last loaded board is still shown', + ) + expect(wrapper.find('[data-testid="paper-board-workspace"]').exists()).toBe(false) + expect(wrapper.findComponent(PaperBoardColumn).exists()).toBe(false) + }) + + it('reports mutation errors without a board-load summary or Retry action', () => { + mockBoardStore.currentBoard = board + mockBoardStore.currentBoardCards = allCards + mockBoardStore.cardsByColumn = cardsByColumn + mockBoardStore.error = 'Failed to create card' + + const wrapper = mountView() + + expect(wrapper.get('[data-testid="paper-board-error"]').text()).toBe('Failed to create card') + expect(wrapper.find('[data-testid="paper-board-load-error"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="paper-board-load-retry"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="paper-board-workspace"]').exists()).toBe(true) + }) + + it('keeps empty-board setup and its local error visible beside a retryable refresh error', async () => { + mockBoardStore.error = 'Board refresh failed' + mockBoardStore.createColumn.mockRejectedValueOnce(new Error('column unavailable')) + + const wrapper = mountView({ boardLoadError: 'Board refresh failed' }) + + await wrapper.get('[data-testid="paper-board-empty-column-name"]').setValue('Backlog') + await wrapper.get('form.paper-board-view__empty-form').trigger('submit') + await flushPromises() + + expect(wrapper.find('[data-testid="paper-board-empty"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="paper-board-empty-error"]').text()).toContain( + 'Could not create the column', + ) + expect(wrapper.get('[data-testid="paper-board-load-retry"]').attributes('disabled')).toBeUndefined() + expect(wrapper.get('[data-testid="paper-board-load-error"]').text()).toContain( + 'Your last loaded board is still shown', + ) + }) }) diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts index d38950fc8..85718c3ff 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts @@ -221,6 +221,57 @@ describe('PaperInboxView', () => { wrapper.unmount() }) + it('closes a stalled archived detail load, restores row focus, and ignores the late payload', async () => { + orchestratorState.isArchivedHistory.value = true + orchestratorState.activeBoardId.value = 'board-archived' + orchestratorState.items.value = [captureRow('history-capture', 'ProposalCreated')] + + let resolveDetail: (detail: CaptureItem) => void = () => undefined + mockCaptureStore.peekDetail.mockReturnValueOnce(new Promise((resolve) => { + resolveDetail = resolve + })) + + const wrapper = mount(PaperInboxView, { + attachTo: document.body, + global: { stubs: { RouterLink: RouterLinkStub } }, + }) + const opener = wrapper.get('[data-testid="capture-history-open"]') + + expect(opener.attributes('aria-expanded')).toBe('false') + await opener.trigger('click') + await wrapper.vm.$nextTick() + + expect(opener.attributes('aria-expanded')).toBe('true') + expect( + wrapper.get('[data-testid="capture-history-detail"] [role="status"]').text(), + ).toContain('Loading') + const close = wrapper.get('[data-testid="capture-history-loading-close"]') + expect(close.text()).toBe('Hide the full retained capture') + expect(mockCaptureStore.peekDetail).toHaveBeenCalledTimes(1) + + close.element.focus() + await close.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.find('[data-testid="capture-history-detail"]').exists()).toBe(false) + expect(opener.attributes('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(opener.element) + + resolveDetail({ + ...captureRow('history-capture', 'ProposalCreated'), + boardId: 'board-archived', + rawText: 'Late retained detail must stay hidden.', + retryCount: 0, + provenance: null, + } as CaptureItem) + await flushPromises() + + expect(wrapper.find('[data-testid="capture-history-detail"]').exists()).toBe(false) + expect(wrapper.text()).not.toContain('Late retained detail must stay hidden.') + expect(mockCaptureStore.peekDetail).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) + it('states that no decision record exists when triage recorded no proposal', async () => { orchestratorState.isArchivedHistory.value = true orchestratorState.activeBoardId.value = 'board-archived' diff --git a/frontend/taskdeck-web/src/tests/views/paper/boardMutationCapabilityParity.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/boardMutationCapabilityParity.spec.ts index a2896cab7..f4adb0072 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/boardMutationCapabilityParity.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/boardMutationCapabilityParity.spec.ts @@ -59,6 +59,7 @@ const CONSUMED_LAYERS = ['store/', 'api/', 'types/', 'i18n/', 'locales/', 'utils const NON_BOARD_SURFACE_MUTATIONS: Record = { createBoard: 'boards LIST surface (BoardsListView), not an open board', updateFilters: 'client-side view state, no server write; Legacy-only FilterPanel', + cancelBackgroundBoardFetch: 'client-side board-read lifecycle cancellation; no server write', createLabel: 'label management modal — a Legacy-only surface, tracked separately', updateLabel: 'label management modal — a Legacy-only surface, tracked separately', deleteLabel: 'label management modal — a Legacy-only surface, tracked separately', @@ -68,7 +69,9 @@ const NON_BOARD_SURFACE_MUTATIONS: Record = { * Action-group factories in `store/board/*` whose writes a board surface must * be able to drive. Structural, so a NEW action added to one of these groups * joins the required set automatically — a `duplicateColumn` would, and no name - * heuristic could be trusted to guess that verb in advance. + * heuristic could be trusted to guess that verb in advance. A brand-new action + * group is NOT discovered automatically: it must be added here, or only actions + * whose names match `MUTATION_NAME` below will be covered. */ const BOARD_SURFACE_GROUPS = ['boardCrud', 'columns', 'cards', 'comments'] diff --git a/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts index 1e2cee2bc..159a8b281 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts @@ -4304,6 +4304,69 @@ describe('PaperReviewView', () => { wrapper.unmount() }) + it('does not restore focus from a stale save after switching A to B and back to A', async () => { + let resolveSave!: (value: unknown) => void + mocks.createRevision.mockImplementationOnce( + () => new Promise((resolve) => { resolveSave = resolve }), + ) + const wrapper = await mountView([ + makeProposal({ id: 'aaa-1', summary: 'First proposal' }), + makeProposal({ id: 'bbb-1', summary: 'Second proposal' }), + ], '/workspace/review', [], [], { attachTo: true }) + + await wrapper.find('[data-serial="#AAA-"]').trigger('click') + await flushPromises() + const opener = wrapper.get('[data-testid="decision-edit"]') + await opener.trigger('click') + await flushPromises() + await wrapper.get('[data-testid="revision-reason"]').setValue('Stale save') + await wrapper.get('[data-testid="revision-save"]').trigger('click') + + await wrapper.find('[data-serial="#BBB-"]').trigger('click') + await flushPromises() + await wrapper.find('[data-serial="#AAA-"]').trigger('click') + await flushPromises() + const currentAOpener = wrapper.get('[data-testid="decision-edit"]').element as HTMLButtonElement + const queueRow = wrapper.find('[data-serial="#AAA-"]').element as HTMLButtonElement + queueRow.focus() + + resolveSave({ + id: 'stale-revision', + proposalId: 'aaa-1', + revisionNumber: 1, + editorUserId: 'u-1', + revisedPayload: '{}', + revisedAt: new Date().toISOString(), + reason: 'Stale save', + createdAt: new Date().toISOString(), + }) + await flushPromises() + await nextTick() + + expect(document.activeElement).toBe(queueRow) + expect(document.activeElement).not.toBe(currentAOpener) + wrapper.unmount() + }) + + it('keeps the revision editor open and focus inside it when save fails', async () => { + mocks.createRevision.mockRejectedValueOnce(new Error('save failed')) + const wrapper = await mountView([makeProposal()], '/workspace/review', [], [], { + attachTo: true, + }) + + await wrapper.get('[data-testid="decision-edit"]').trigger('click') + await flushPromises() + const editor = wrapper.get('[data-testid="revision-editor"]') + await editor.get('[data-testid="revision-reason"]').setValue('Will fail') + await editor.get('[data-testid="revision-save"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="revision-editor"]').exists()).toBe(true) + expect(editor.element.contains(document.activeElement)).toBe(true) + expect(mocks.errorToast).toHaveBeenCalledWith('save failed') + wrapper.unmount() + }) + it('states on the rail why the decisions are disabled, and offers the exit there', async () => { const wrapper = await mountView([makeProposal()]) diff --git a/frontend/taskdeck-web/src/views/BoardView.vue b/frontend/taskdeck-web/src/views/BoardView.vue index 3cfea25f4..f94403205 100644 --- a/frontend/taskdeck-web/src/views/BoardView.vue +++ b/frontend/taskdeck-web/src/views/BoardView.vue @@ -21,6 +21,7 @@ import type { Card } from '../types/board' import type { BoardPresenceMember } from '../types/realtime' import type { CardFilters } from '../store/boardStore' import { isClientOnboardingDemoBoardName } from '../utils/boardDemo' +import { getErrorMessage } from '../utils/errorMessage' import { logError } from '../utils/errorReporting' const route = useRoute() @@ -70,13 +71,20 @@ function normalizePresenceMembers(members: BoardPresenceMember[]): BoardPresence } const boardId = ref(route.params.id as string) +const boardLoadRetryInFlight = ref(false) +const boardLoadError = ref(null) +const routedBoard = computed(() => boardStore.currentBoard?.id === boardId.value + ? boardStore.currentBoard + : null) +let viewUnmounted = false +let realtimeStarted = false const realtime = createBoardRealtimeController({ - fetchBoard: async (id: string) => { - if (id !== boardId.value) { + fetchBoard: async (id: string, options: { intent: 'background' }) => { + if (viewUnmounted || id !== boardId.value) { return } - await boardStore.fetchBoard(id) + await boardStore.fetchBoard(id, options) }, onPresenceChanged: (snapshot) => { if (snapshot.boardId !== boardId.value) { @@ -89,15 +97,58 @@ const realtime = createBoardRealtimeController({ }, }) +const boardLoadErrorSummary = computed(() => routedBoard.value + ? "We couldn't refresh this board. Your last loaded board is still shown." + : "We couldn't load this board.") + +function recordBoardLoadFailure(requestedBoardId: string, error: unknown) { + if (viewUnmounted || boardId.value !== requestedBoardId) return + + boardLoadError.value = boardStore.error ?? getErrorMessage(error, 'Failed to fetch board') +} + +async function retryBoardLoad() { + if (viewUnmounted || boardLoadRetryInFlight.value) return + + const requestedBoardId = boardId.value + if (!requestedBoardId) return + + boardLoadRetryInFlight.value = true + + try { + const committed = await boardStore.fetchBoard(requestedBoardId) + if (viewUnmounted || !committed || boardId.value !== requestedBoardId) return + + boardLoadError.value = null + try { + if (realtimeStarted) { + await realtime.switchBoard(requestedBoardId) + } else { + await realtime.start(requestedBoardId) + } + realtimeStarted = true + } catch (error) { + logError('Failed to resume board realtime after retry:', error) + } + } catch (error) { + recordBoardLoadFailure(requestedBoardId, error) + logError('Failed to retry board load:', error) + } finally { + if (!viewUnmounted) boardLoadRetryInFlight.value = false + } +} + // Sort columns by position const sortedColumns = computed(() => { - if (!boardStore.currentBoard) return [] - return [...boardStore.currentBoard.columns].sort((a, b) => a.position - b.position) + if (!routedBoard.value) return [] + return [...routedBoard.value.columns].sort((a, b) => a.position - b.position) }) -const isDemoBoard = computed(() => isClientOnboardingDemoBoardName(boardStore.currentBoard?.name)) +const isDemoBoard = computed(() => isClientOnboardingDemoBoardName(routedBoard.value?.name)) const paperCollapsedColumnIds = ref>(new Set()) const paperCardsByColumn = computed>(() => { const map = new Map() + if (!routedBoard.value) return map + for (const card of boardStore.currentBoardCards) { if (!map.has(card.columnId)) { map.set(card.columnId, []) @@ -146,6 +197,10 @@ const { (columnId) => !paperOn.value || !paperCollapsedColumnIds.value.has(columnId), ) +const selectedColumnId = computed( + () => sortedColumns.value[selectedColumnIndex.value]?.id ?? null, +) + function handlePaperCollapsedColumnsChange(columnIds: string[]) { paperCollapsedColumnIds.value = new Set(columnIds) @@ -191,10 +246,17 @@ onMounted(async () => { applyPresenceSeed() boardStore.setEditingCard(null) const committed = await boardStore.fetchBoard(requestedBoardId) - if (committed && boardId.value === requestedBoardId) { - await realtime.start(requestedBoardId) + if (!viewUnmounted && committed && boardId.value === requestedBoardId) { + boardLoadError.value = null + try { + await realtime.start(requestedBoardId) + realtimeStarted = true + } catch (error) { + logError('Failed to start board realtime:', error) + } } } catch (error) { + recordBoardLoadFailure(requestedBoardId, error) logError('Failed to load board:', error) } finally { boardLoadPerf.end() @@ -210,6 +272,7 @@ watch( } boardId.value = nextBoardId + boardLoadError.value = null resetSelection() // Seed with current user on board switch for the same reason as onMounted. applyPresenceSeed() @@ -217,10 +280,17 @@ watch( try { const committed = await boardStore.fetchBoard(nextBoardId) - if (committed && boardId.value === nextBoardId) { - await realtime.switchBoard(nextBoardId) + if (!viewUnmounted && committed && boardId.value === nextBoardId) { + boardLoadError.value = null + try { + await realtime.switchBoard(nextBoardId) + realtimeStarted = true + } catch (error) { + logError('Failed to switch board realtime:', error) + } } } catch (error) { + recordBoardLoadFailure(nextBoardId, error) logError('Failed to switch board:', error) } } @@ -234,6 +304,8 @@ watch( ) onBeforeUnmount(() => { + viewUnmounted = true + boardStore.cancelBackgroundBoardFetch?.(boardId.value) presenceMembers.value = [] boardStore.setBoardPresenceMembers([]) boardStore.setEditingCard(null) @@ -241,7 +313,7 @@ onBeforeUnmount(() => { }) async function createColumn() { - if (!newColumnName.value.trim()) return + if (!routedBoard.value || !newColumnName.value.trim()) return try { await boardStore.createColumn(boardId.value, { @@ -292,6 +364,8 @@ function openBoardChat() { } function openBoardCardComposer() { + if (!routedBoard.value) return + if (sortedColumns.value.length === 0) { showColumnForm.value = true return @@ -348,7 +422,7 @@ function closeOpenUi() { } function standardBoardOnlyShortcutsEnabled() { - return !paperOn.value + return Boolean(routedBoard.value) && !paperOn.value } /** @@ -369,7 +443,7 @@ const paperDialogOpen = ref(false) * and gating it here would only risk stranding one open. */ function boardShortcutsEnabled() { - return !paperDialogOpen.value + return Boolean(routedBoard.value) && !paperDialogOpen.value } // Setup keyboard shortcuts @@ -422,18 +496,22 @@ useKeyboardShortcuts([
-
+
+ + + + + + -
+
Loading board...
@@ -533,21 +646,14 @@ useKeyboardShortcuts([
- -
- -
-
@@ -233,7 +241,7 @@ function onCardDragOver(card: Card, e: DragEvent) { class="paper-board-column__ctl" :aria-label="t('boardDetail.column.moveRight')" :title="t('boardDetail.column.moveRight')" - :disabled="!canMoveRight" + :disabled="reorderBusy || !canMoveRight" data-testid="paper-column-move-right" @click="onMoveRight" > @@ -335,6 +343,11 @@ function onCardDragOver(card: Card, e: DragEvent) { transition: border-color var(--d-quick) var(--ease-paper); } +.paper-board-column--selected { + border-color: var(--ink-deep); + box-shadow: 0 0 0 1px var(--ink-deep), var(--shadow-press); +} + .paper-board-column--drag-over { border-color: var(--ember); box-shadow: 0 0 0 1px var(--ember), var(--shadow-press); diff --git a/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue b/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue index dc526ef18..c7682ec07 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperBoardView.vue @@ -37,8 +37,20 @@ const props = withDefaults( /** Card visual variant — propagated to every column. */ cardVariant?: PaperBoardCardVariant selectedCardId?: string | null + /** Lane currently targeted by BoardView's keyboard model. */ + selectedColumnId?: string | null + /** Stable error copy retained by BoardView while an explicit retry is in flight. */ + boardLoadError?: string | null + /** BoardView owns the actual load and realtime subscription transition. */ + boardLoadRetrying?: boolean }>(), - { cardVariant: 'index', selectedCardId: null }, + { + cardVariant: 'index', + selectedCardId: null, + selectedColumnId: null, + boardLoadError: null, + boardLoadRetrying: false, + }, ) const emit = defineEmits<{ @@ -59,6 +71,8 @@ const emit = defineEmits<{ (event: 'collapsed-columns-change', columnIds: string[]): void /** Keeps BoardView's logical lane selection aligned with a focused lane control. */ (event: 'column-select', columnId: string): void + /** Requests a fresh load from BoardView, which also owns realtime startup/switching. */ + (event: 'retry-board-load'): void }>() const route = useRoute() @@ -68,6 +82,12 @@ const { t } = useI18n() const { mode: viewportMode } = useViewportMode() const boardId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')) +const routedBoard = computed(() => boardStore.currentBoard?.id === boardId.value + ? boardStore.currentBoard + : null) +const boardLoadErrorSummary = computed(() => routedBoard.value + ? "We couldn't refresh this board. Your last loaded board is still shown." + : "We couldn't load this board.") const selectedCard = ref(null) const pendingCard = ref(null) const pendingNavigation = ref<{ resolve: (allow: boolean) => void } | null>(null) @@ -188,8 +208,8 @@ function changeColumnWidth(event: Event) { } const sortedColumns = computed(() => { - if (!boardStore.currentBoard) return [] - return [...boardStore.currentBoard.columns].sort((a, b) => a.position - b.position) + if (!routedBoard.value) return [] + return [...routedBoard.value.columns].sort((a, b) => a.position - b.position) }) const activeCollapsedColumnIds = computed(() => sortedColumns.value @@ -227,6 +247,7 @@ function toggleColumnCollapse(column: Column) { const cardsByColumn = computed>(() => { const map = new Map() + if (!routedBoard.value) return map for (const card of boardStore.currentBoardCards) { if (!map.has(card.columnId)) { @@ -265,7 +286,7 @@ const { handleColumnDragEnd, handleColumnDragOver, handleColumnDragLeave, - handleColumnDrop, + handleColumnDrop: performColumnDrop, handleCardDragStart, handleCardDragEnd, } = useBoardDragDrop(() => boardId.value, sortedColumns) @@ -511,12 +532,32 @@ async function createCardInColumn(column: Column, title: string) { } } +const columnReorderBusy = ref(false) + +async function onColumnDrop(column: Column, event: DragEvent) { + event.preventDefault() + if (!draggedColumn.value) return + if (columnReorderBusy.value) { + handleColumnDragLeave() + return + } + + columnReorderBusy.value = true + try { + await performColumnDrop(column, event) + } finally { + columnReorderBusy.value = false + } +} + /** * Keyboard/pointer column reorder, alongside the existing drag handle. Drag is * the only reorder Legacy offers; it is unusable without a pointer, so Paper * adds explicit controls over the same `reorderColumns` action. */ async function moveColumn(column: Column, direction: 'left' | 'right') { + if (columnReorderBusy.value) return + const columns = sortedColumns.value const index = columns.findIndex((c) => c.id === column.id) const targetIndex = direction === 'left' ? index - 1 : index + 1 @@ -527,6 +568,7 @@ async function moveColumn(column: Column, direction: 'left' | 'right') { if (!removed) return reordered.splice(targetIndex, 0, removed) + columnReorderBusy.value = true try { await boardStore.reorderColumns( boardId.value, @@ -534,6 +576,8 @@ async function moveColumn(column: Column, direction: 'left' | 'right') { ) } catch (error) { logError('Failed to reorder columns (paper):', error) + } finally { + columnReorderBusy.value = false } } @@ -567,14 +611,15 @@ const firstColumnName = ref('') const creatingColumns = ref(false) const columnError = ref(null) -/** - * A loaded board that has no columns. The empty state takes precedence over the - * generic board error banner here so a failed column create keeps the recovery - * affordance on screen instead of replacing it with a bare error. - */ -const isEmptyBoard = computed(() => Boolean(boardStore.currentBoard) && sortedColumns.value.length === 0) +/** A loaded board that has no columns. */ +const isEmptyBoard = computed(() => Boolean(routedBoard.value) && sortedColumns.value.length === 0) -const emptyStateError = computed(() => columnError.value ?? boardStore.error) +// Column creation owns its local recovery copy. Other mutation errors retain +// the existing empty-state fallback, while an explicit board-load error stays +// in the additive Retry alert above instead of being rendered twice. +const emptyStateError = computed( + () => columnError.value ?? (props.boardLoadError ? null : boardStore.error), +) const canSubmitFirstColumn = computed( () => firstColumnName.value.trim().length > 0 && !creatingColumns.value, @@ -677,7 +722,7 @@ async function addStarterColumns() {
Board

- {{ boardStore.currentBoard?.name ?? 'Board' }} + {{ routedBoard?.name ?? 'Board' }}

{{ totalCards }} cards · {{ sortedColumns.length }} columns @@ -715,7 +760,7 @@ async function addStarterColumns() { @click="toggleDensity" />

+ +
@@ -805,7 +869,7 @@ async function addStarterColumns() { neither the column-bootstrap empty state nor an empty lane rail. -->
@@ -829,7 +893,7 @@ async function addStarterColumns() { @dragend="handleColumnDragEnd" @dragover="(event) => { handleColumnDragOver(column, event); onCardDragOverColumn(column, event) }" @dragleave="handleColumnDragLeave" - @drop="(event) => { handleColumnDrop(column, event); if (draggedCard) onCardDropOnColumn(column, event) }" + @drop="(event) => { onColumnDrop(column, event); if (draggedCard) onCardDropOnColumn(column, event) }" > | null>(null) let applyReturnFocusEl: HTMLElement | null = null let revisionReturnFocusEl: HTMLElement | null = null let revisionReturnFocusProposalId: string | null = null +let revisionEditEpoch = 0 +let revisionReturnFocusEpoch: number | null = null // The rail's primary control, whichever it currently is: the decision button, // or the filing button the rail becomes once the proposal is applied and @@ -1163,28 +1165,49 @@ function revisionRailFocusTarget(): HTMLElement | null { * The revision composer lives below the rail and moves focus into its first * field on entry. Capture the rail action before that move so both deliberate * exits (cancel and a successful save) return the reviewer to the control that - * opened the editor. A proposal-id guard prevents a late save from moving focus - * onto a different proposal after the queue selection changes. + * opened the editor. An edit-session epoch prevents a late save from moving + * focus into a new session after the queue selection changes, even when the + * reviewer returns to the same proposal. */ function captureRevisionReturnFocus(proposalId: string) { + const epoch = ++revisionEditEpoch revisionReturnFocusEl = revisionRailFocusTarget() revisionReturnFocusProposalId = proposalId + revisionReturnFocusEpoch = epoch +} + +function invalidateRevisionFocusSession() { + revisionEditEpoch += 1 + revisionReturnFocusEl = null + revisionReturnFocusProposalId = null + revisionReturnFocusEpoch = null } function restoreRevisionFocus() { const captured = revisionReturnFocusEl const proposalId = revisionReturnFocusProposalId + const epoch = revisionReturnFocusEpoch revisionReturnFocusEl = null revisionReturnFocusProposalId = null + revisionReturnFocusEpoch = null - if (!proposalId) return + if (!proposalId || epoch === null || epoch !== revisionEditEpoch) return void nextTick(() => { + if (epoch !== revisionEditEpoch) return if (!proposalIdsEqual(activeProposal.value?.id, proposalId)) return const target = captured?.isConnected ? captured : revisionRailFocusTarget() if (target && isFocusable(target)) target.focus?.() }) } +watch( + () => activeProposal.value?.id ?? null, + (id, previousId) => { + if (previousId === undefined || proposalIdsEqual(id, previousId)) return + invalidateRevisionFocusSession() + }, +) + watch(executeConfirmProposal, (pending, previous) => { // Only on close (open → closed), never on the open itself. if (pending !== null || !previous) return @@ -1681,6 +1704,7 @@ async function onPreviewDiff() { async function onSaveRevision(payload: Parameters[0]) { if (isArchivedHistory.value) return const proposalId = activeProposal.value?.id + const saveEpoch = revisionEditEpoch await saveRevision(payload) // Saving an edit changes what Apply will execute, so a diff already on screen is // now stale — drop it so the "reflects your saved edit" note cannot certify a @@ -1693,6 +1717,8 @@ async function onSaveRevision(payload: Parameters[0]) { // only for that close and only if the reviewer is still on the same proposal. if ( proposalId && + saveEpoch === revisionEditEpoch && + revisionReturnFocusEpoch === saveEpoch && !revisionEditing.value && proposalIdsEqual(activeProposal.value?.id, proposalId) ) { @@ -1828,6 +1854,9 @@ onUnmounted(() => { function selectProposal(id: string) { // An explicit choice supersedes the #2215 A notice. + if (!proposalIdsEqual(activeProposal.value?.id, id)) { + invalidateRevisionFocusSession() + } activeProposalSettledElsewhere.value = null decisionReceipt.value = null explicitActiveId.value = id diff --git a/frontend/taskdeck-web/src/views/paper/board/PaperBoardDialogShell.vue b/frontend/taskdeck-web/src/views/paper/board/PaperBoardDialogShell.vue index 6247eeb29..afb421b82 100644 --- a/frontend/taskdeck-web/src/views/paper/board/PaperBoardDialogShell.vue +++ b/frontend/taskdeck-web/src/views/paper/board/PaperBoardDialogShell.vue @@ -1,7 +1,9 @@