diff --git a/backend/src/Taskdeck.Application/Interfaces/IWorkspaceInsightRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IWorkspaceInsightRepository.cs index 1e7f4e2fa0..337d605f1f 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IWorkspaceInsightRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IWorkspaceInsightRepository.cs @@ -2,7 +2,7 @@ namespace Taskdeck.Application.Interfaces; -public enum ObservationSaveOutcome { Saved, SourceChanged, StorageBusy } +public enum ObservationSaveOutcome { Saved, SourceChanged, StorageBusy, ConcurrentWrite } public interface IWorkspaceInsightRepository { diff --git a/backend/src/Taskdeck.Application/Services/WorkspaceObservationContract.cs b/backend/src/Taskdeck.Application/Services/WorkspaceObservationContract.cs index b21d28622e..c82444b19a 100644 --- a/backend/src/Taskdeck.Application/Services/WorkspaceObservationContract.cs +++ b/backend/src/Taskdeck.Application/Services/WorkspaceObservationContract.cs @@ -18,7 +18,7 @@ Suggest zero to three useful clarification questions about the supplied card. kind is next-step, outcome, or dependency. Use each kind at most once. question is a question, not a factual claim or instruction to change a board (max 240 characters). reason explains why asking is useful (max 600 characters). quote is an exact, nonempty excerpt - from the supplied Text (max 400 characters). Cite only this source; do not invent people, dates, + from the supplied excerpt (max 400 characters). Cite only this source; do not invent people, dates, dependencies, missing decisions or urgency. Do not repeat an already answered question, paraphrase a clear next step as a problem, or ask for detail that would not change the work. Return [] when the source already gives a clear outcome and next step. No Markdown, tools or board operations. diff --git a/backend/src/Taskdeck.Application/Services/WorkspaceObservationService.cs b/backend/src/Taskdeck.Application/Services/WorkspaceObservationService.cs index 7297db49f5..011bedeeed 100644 --- a/backend/src/Taskdeck.Application/Services/WorkspaceObservationService.cs +++ b/backend/src/Taskdeck.Application/Services/WorkspaceObservationService.cs @@ -31,7 +31,7 @@ public async Task>> GenerateAsync(Guid userId, Gene if (health.IsMock || !health.IsAvailable) return Result.Failure>(ErrorCodes.InvalidOperation, "Model analysis needs an available configured provider. Structural analysis remains available."); - var request = new ChatCompletionRequest([new("user", JsonSerializer.Serialize(source))], MaxTokens: 1600, Temperature: 0.2, + var request = new ChatCompletionRequest([new("user", source.Text)], MaxTokens: 1600, Temperature: 0.2, Attribution: new(userId, Guid.NewGuid().ToString("N"), LlmRequestSourceSurface.Chat, dto.BoardId), SystemPrompt: WorkspaceObservationContract.Prompt); // UTF-8 byte count is a conservative input-token ceiling, plus bounded output and framing. @@ -89,6 +89,8 @@ public async Task>> GenerateAsync(Guid userId, Gene return await repository.SaveObservationAsync(userId, dto.BoardId, dto.CardId, source.Fingerprint, ct) switch { ObservationSaveOutcome.Saved => Result.Success(result), + ObservationSaveOutcome.ConcurrentWrite => Result.Failure>(ErrorCodes.Conflict, + "Another request changed your question results. This request saved no observations. Reload Quiet insights to read the current results. Model usage was already accounted for; analyzing again uses budget again."), ObservationSaveOutcome.StorageBusy => Result.Failure>(ErrorCodes.UnexpectedError, "Storage was busy. No observations were saved. Model usage was already accounted for; analyzing again uses budget again. Wait, then preview the evidence before deciding whether to retry."), _ => Changed(), diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/WorkspaceInsightRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/WorkspaceInsightRepository.cs index df9a763a13..83e7ac57e6 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/WorkspaceInsightRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/WorkspaceInsightRepository.cs @@ -55,7 +55,8 @@ public async Task SaveObservationAsync(Guid userId, Guid committed = true; return ObservationSaveOutcome.Saved; } - catch (DbUpdateConcurrencyException) { return ObservationSaveOutcome.SourceChanged; } + catch (DbUpdateConcurrencyException) { return ObservationSaveOutcome.ConcurrentWrite; } + catch (DbUpdateException ex) when (ex.InnerException is Microsoft.Data.Sqlite.SqliteException { SqliteExtendedErrorCode: 2067 }) { return ObservationSaveOutcome.ConcurrentWrite; } catch (DbUpdateException ex) when (ex.InnerException is Microsoft.Data.Sqlite.SqliteException { SqliteErrorCode: 19 }) { return ObservationSaveOutcome.SourceChanged; } catch (DbUpdateException ex) when (ex.InnerException is Microsoft.Data.Sqlite.SqliteException { SqliteErrorCode: 5 or 6 }) { return ObservationSaveOutcome.StorageBusy; } catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.SqliteErrorCode is 5 or 6) { return ObservationSaveOutcome.StorageBusy; } diff --git a/backend/tests/Taskdeck.Api.Tests/WorkspaceObservationApiTests.cs b/backend/tests/Taskdeck.Api.Tests/WorkspaceObservationApiTests.cs index 69576fa1cf..cbad248834 100644 --- a/backend/tests/Taskdeck.Api.Tests/WorkspaceObservationApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/WorkspaceObservationApiTests.cs @@ -117,6 +117,41 @@ public async Task BusyWriteLockIsNotSourceChangeAndRejectedQuestionsCannotLeakIn (await db.Set().CountAsync(x => x.BoardId == board)).Should().Be(0); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompetingQuestionWritesReportConcurrencyWithoutChangingTheSourceOrLeakingTheLoser(bool updateExisting) + { + using var app = WithProvider(new Provider()); var (_, board, card, source) = await Setup(app); + using var scope = app.Services.CreateScope(); var first = scope.ServiceProvider.GetRequiredService(); + var user = (await first.Boards.FindAsync(board))!.OwnerId!.Value; + await using var second = new TaskdeckDbContext(new DbContextOptionsBuilder() + .UseSqlite(first.Database.GetConnectionString()!).Options); + var firstRepository = new WorkspaceInsightRepository(first); var secondRepository = new WorkspaceInsightRepository(second); + var winner = new QuietInsight(user, board, "model-question-next-step", card.ToString(), card, null); + firstRepository.Add(winner); + QuietInsight loser; + if (updateExisting) + { + await first.SaveChangesAsync(); + loser = await second.Set().SingleAsync(x => x.Id == winner.Id); + } + else + { + loser = new QuietInsight(user, board, winner.Rule, winner.TargetKey, card, null); + secondRepository.Add(loser); + } + // Both requests hold their question state before either attempts its observation commit. + winner.Refresh("First result", "Winner", "Original evidence", DateTimeOffset.UtcNow); + loser.Refresh("Second result", "Loser", "Different generated evidence", DateTimeOffset.UtcNow); + (await firstRepository.SaveObservationAsync(user, board, card, source.Fingerprint, default)).Should().Be(ObservationSaveOutcome.Saved); + (await secondRepository.SaveObservationAsync(user, board, card, source.Fingerprint, default)).ToString().Should().Be("ConcurrentWrite"); + await second.SaveChangesAsync(); + var retained = await second.Set().AsNoTracking().SingleAsync(x => x.BoardId == board); + retained.Title.Should().Be("First result"); retained.Evidence.Should().Be("Original evidence"); + (await new WorkspaceObservationReader(second).SourceAsync(user, board, card, default))!.Fingerprint.Should().Be(source.Fingerprint); + } + private sealed class Provider : ILlmProvider { public int Calls; @@ -163,7 +198,8 @@ public async Task QuotedQuestions_ArePrivateDeduplicatedAndDoNotChangeBoard() var again = await Generate(client, board, source); again.EnsureSuccessStatusCode(); (await again.Content.ReadFromJsonAsync>())!.Single().Id.Should().Be(insight.Id); provider.Request!.Messages.Should().ContainSingle(); - provider.Request.Messages[0].Content.Should().Contain(source.Fingerprint); + provider.Request.Messages[0].Content.Should().Be(source.Text) + .And.NotContain(source.Fingerprint).And.NotContain(cardId.ToString()); (await client.GetFromJsonAsync>($"/api/workspace-insights?boardId={board}"))!.Single().State.Should().Be("available"); (await client.PatchAsJsonAsync($"/api/workspace-insights/{insight.Id}", new InsightActionDto("dismiss"))).EnsureSuccessStatusCode(); var dismissed = await Generate(client, board, source); dismissed.EnsureSuccessStatusCode(); diff --git a/backend/tests/Taskdeck.Application.Tests/Services/WorkspaceObservationTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/WorkspaceObservationTests.cs index 39ab33387b..81a67694bf 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/WorkspaceObservationTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/WorkspaceObservationTests.cs @@ -11,6 +11,23 @@ namespace Taskdeck.Application.Tests.Services; public class WorkspaceObservationTests { + [Fact] + public async Task ConcurrentWriteReportsCurrentResultsAndAlreadyAccountedUsageWithoutRetry() + { + var (service, _, provider, quota, _, repository, source) = Setup(); + provider.Setup(x => x.CompleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new LlmCompletionResult("""[{"kind":"next-step","question":"What next?","reason":"Action unclear","quote":"Investigate"}]""", 77, false, Provider: "Fixture", Model: "fixture")); + repository.Setup(x => x.SaveObservationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ObservationSaveOutcome.ConcurrentWrite); + var result = await service.GenerateAsync(Guid.NewGuid(), new(Guid.NewGuid(), source.CardId, source.Fingerprint), default); + result.IsSuccess.Should().BeFalse(); result.ErrorCode.Should().Be("Conflict"); + result.ErrorMessage.Should().Contain("Another request").And.Contain("This request saved no observations") + .And.Contain("Reload Quiet insights").And.Contain("uses budget again").And.NotContain("source changed"); + provider.Verify(x => x.CompleteAsync(It.IsAny(), It.IsAny()), Times.Once); + quota.Verify(x => x.CommitReservationAsync(It.IsAny(), It.IsAny(), LlmSurface.Chat, + "Fixture", "fixture", 77, 0, CancellationToken.None), Times.Once); + } + [Fact] public async Task BusyStorageReportsUnsavedOutcomeAndAlreadyAccountedUsageWithoutRetry() { diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 22a2943a22..686d913610 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -2,9 +2,11 @@ Last Updated: 2026-09-10 -Finish exact-head hosted delivery of #2892, the implemented reminder-hours slice under #2808: optional named-zone weekly windows, explicit save/recovery, existing shared budgets and server-side eligibility. Local full backend/frontend, combined privacy/recovery and cross-browser proof pass. The final active-account write condition prevents erased private hours from being restored by an in-flight request. Old opted-in users retain unrestricted behavior; reminders remain default off. +The #2893/#2894 recovery follow-up is implemented in [PR #2895](https://github.com/Chris0Jeky/Taskdeck/pull/2895): preserve reminder drafts, report concurrent question writes accurately and align model payloads with the evidence preview. Keep the existing privacy/revision/usage boundaries, prove the reproduced cases and publish one reviewed follow-up. Local full suites, focused final payload checks, browser recovery and bounded independent review pass. -Overhaul closeout (#2808): #2866 and #2886 are merged with green required CI. All earlier continuation PRs are included; #2892 is the remaining delivery. Reconcile the tracker after that final merge. Scope reconciliation keeps generic processor hosting (#2258), historical representation migration (#2260) and undefined source-storage restore outside the requested prototype delivery. Retain live-provider/device/usefulness acceptance as unverified rather than inferring it from synthetic tests. Earlier execution notes below record the completed slice sequence. +Merged #2892 as 93eab3443 after required run 34453924959 passed at reviewed head 42965edde. The reminder-hours slice under #2808 delivers: optional named-zone weekly windows, explicit save/recovery, existing shared budgets and server-side eligibility. Local full backend/frontend, combined privacy/recovery and cross-browser proof pass. The final active-account write condition prevents erased private hours from being restored by an in-flight request. Old opted-in users retain unrestricted behavior; reminders remain default off. + +Overhaul closeout (#2808): #2866 and #2886 are merged with green required CI. All earlier continuation PRs are included; #2892 is also merged. Reconcile the tracker after the final recovery follow-up merges. Scope reconciliation keeps generic processor hosting (#2258), historical representation migration (#2260) and undefined source-storage restore outside the requested prototype delivery. Retain live-provider/device/usefulness acceptance as unverified rather than inferring it from synthetic tests. Earlier execution notes below record the completed slice sequence. Grounded-question commit recovery (#2808): close the final source-read/save interval and settle accounting before staging questions. Keep short transaction ownership in the insight repository, preserve source/access and insight concurrency checks, and prove no rejected staged state can leak into subsequent saves. This completes the two previously tracked observation consistency gaps; general semantic processing and recall remain separate. diff --git a/docs/STATUS.md b/docs/STATUS.md index 8786788f0a..2d0e3abd60 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,11 @@ Last Updated: 2026-09-10 -Optional reminder hours (#2808, pending delivery): explicit weekday and time windows in an IANA zone, including overnight and daylight-saving behavior. Server checks precede question lookup and budget admission; preference edits preserve the shared UTC allowance, old-client toggles retain the window, and exports include it without a new migration. The slice passed 9,270 backend and 6,421 frontend tests, with 34/three existing skips respectively. After parent integration, 56 API tests, 56 component tests, typecheck/build and five Chromium journeys pass, including a reproduced and fixed account-erasure race. Firefox and mobile Grove/Grove Night evidence are recorded in the validation ledger. Exact-head hosted qualification remains pending. [Policy](product/WORKSPACE_ATTENTION.md). +Final overhaul recovery follow-up (#2893/#2894, [PR #2895](https://github.com/Chris0Jeky/Taskdeck/pull/2895)): retain an unsaved hours draft across enable-only saves and confirmed validation rejections, distinguish competing question writes from source changes, and send the model exactly the previewed excerpt. Final frontend passes 6,436 tests with three existing skips; full backend passes 9,315 with 34 existing skips, followed by 17 API and 19 application tests for the final payload correction. Build/typecheck, scoped lint, browser recovery and bounded independent review pass. Required hosted runs 34458797270 and 34462673996 passed at reviewed heads f39cc6cf3 and d25edf2a5, including both operating-system suites and browser smoke. The PR records subsequent base qualification and the final merge receipt. The source and draft regressions were reproduced before repair; the validation ledger records their scope. -Overhaul integrations #2866 and #2886 are merged at `384d8dfaa` and `f00cf1d31` respectively. Required CI passed at the reviewed #2886 head `fce97dc67`, including both operating-system API/unit suites and the final browser smoke job. The continuation delivers explicit transcription, route-exit recovery and grounded commit/account-erasure protection. Final local 6,428 frontend tests and ten browser journeys pass; the full backend's single unchanged worker-wait failure passes in the 30-case isolated/fix verification. Reminder work-hours/time-zone selection is implemented in #2892 and awaits its final hosted gate. Earlier pending-delivery notes below are historical slice evidence superseded by these merge receipts. [Full evidence](product/WORKSPACE_OVERHAUL_VALIDATION.md). +Optional reminder hours (#2808, merged #2892): explicit weekday and time windows in an IANA zone, including overnight and daylight-saving behavior. Server checks precede question lookup and budget admission; preference edits preserve the shared UTC allowance, old-client toggles retain the window, and exports include it without a new migration. The slice passed 9,270 backend and 6,421 frontend tests, with 34/three existing skips respectively. After parent integration, 56 API tests, 56 component tests, typecheck/build and five Chromium journeys pass, including a reproduced and fixed account-erasure race. Firefox and mobile Grove/Grove Night evidence are recorded in the validation ledger. Required CI run 34453924959 passed at reviewed head 42965edde; #2892 merged as 93eab3443. [Policy](product/WORKSPACE_ATTENTION.md). + +Overhaul integrations #2866 and #2886 are merged at `384d8dfaa` and `f00cf1d31` respectively. Required CI passed at the reviewed #2886 head `fce97dc67`, including both operating-system API/unit suites and the final browser smoke job. The continuation delivers explicit transcription, route-exit recovery and grounded commit/account-erasure protection. Final local 6,428 frontend tests and ten browser journeys pass; the full backend's single unchanged worker-wait failure passes in the 30-case isolated/fix verification. Reminder work-hours/time-zone selection is merged in #2892. Earlier pending-delivery notes below are historical slice evidence superseded by these merge receipts. [Full evidence](product/WORKSPACE_OVERHAUL_VALIDATION.md). Grounded-question commit recovery (#2808, pending main delivery): recheck source/access inside the same serializable transaction that saves observations, and detach rejected staged questions. Settle model usage before staging; accounting failure now has a definite no-save result and cannot hide a successful question save. Seventeen application and sixteen API/reminder tests pass, including changes after the final service read. Full backend verification passes 9,240 tests with 34 existing skips; bounded independent review is clean. Hosted qualification remains separate. diff --git a/docs/product/GROUNDED_OBSERVATIONS.md b/docs/product/GROUNDED_OBSERVATIONS.md index 3e21f14254..d7f15eaf51 100644 --- a/docs/product/GROUNDED_OBSERVATIONS.md +++ b/docs/product/GROUNDED_OBSERVATIONS.md @@ -20,6 +20,11 @@ batch. Existing structural **Analyze now** remains available with no model confi A busy SQLite writer reports a storage failure rather than claiming the evidence changed. No candidate is saved; the message explains that model usage was already accounted for and that a new analysis uses budget again. The final transaction still detaches rejected candidates. + A competing question insert or revision update reports a concurrent write instead of a changed + source. This request saves no candidates; reload Quiet insights to read the current results before + deciding whether to spend budget on another analysis. The client preserves that specific recovery + message. Fingerprints and card identifiers remain server-side; the model user message contains + exactly the previewed excerpt. - The producer shares the user's Chat request/token budget and kill switch. Its conservative input estimate includes UTF-8 source/prompt bytes, bounded output and framing. Dispatch-aware settlement retains billed usage after cancellation or invalid output. For accepted output, settlement occurs diff --git a/docs/product/WORKSPACE_ATTENTION.md b/docs/product/WORKSPACE_ATTENTION.md index 1ba922ebf7..77b5d0bf04 100644 --- a/docs/product/WORKSPACE_ATTENTION.md +++ b/docs/product/WORKSPACE_ATTENTION.md @@ -12,7 +12,10 @@ Optional **Reminder hours** restricts new reminders to selected weekdays and loc IANA time zone, such as `Europe/London`. The default is unrestricted for existing opted-in accounts; reminders themselves remain off by default. **Use this device's time zone** fills the field, and **Save reminder hours** explicitly persists the choice across devices. Toggling reminders off/on -preserves the window. Clearing the restriction requires an explicit hours save. +preserves the saved window and any unsaved hours draft in the same account. Clearing the restriction +requires an explicit hours save. Confirmed validation rejections retain the draft for correction. +Account changes and uncertain saves clear the local form; reload +the server preference before continuing. The start is included and the end excluded. An overnight window belongs to each selected starting day: Monday 22:00–02:00 includes early Tuesday. Equal start/end times and invalid zones/days are diff --git a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md index 9628ed94a0..9520a0044a 100644 --- a/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md +++ b/docs/product/WORKSPACE_OVERHAUL_VALIDATION.md @@ -1,14 +1,52 @@ # Workspace overhaul validation and follow-through +## Final recovery follow-up (2026-09-10) + +The hours draft regression failed in both enable-toggle directions before the repair. A confirmed +HTTP 400 validation rejection also discarded the draft; its regression failed before repair. +Twenty attention component cases now cover these fixes, account changes and uncertain-save recovery. +Final full frontend verification passes 6,436 tests with three existing skips across 416 files. +Three Chromium journeys pass in 2.6 minutes against isolated SQLite: both reminder journeys and +grounded stale-evidence recovery. The hours journey preserves the actual unsaved draft through both +real enable-only HTTP saves, then saves it separately and continues through revision conflict, +experience switching, Grove/Grove Night, mobile layout and accessibility checks. + +After the validation fix, both reminder journeys passed again in 1.2 minutes, including an actual +invalid-zone HTTP 400, retained fields and correction. Final typecheck/build and scoped ESLint pass. +The first final typecheck caught a missing error-mapper fallback argument; it was corrected before +the successful build. Temporary browser services are stopped. + +Two real SQLite competing-write cases failed before the outcome repair: independently prepared +inserts and updates both reported SourceChanged despite unchanged evidence. The repair distinguishes +ConcurrentWrite, retains the winning question/evidence and detaches the losing staged state. Service +and component checks cover truthful recovery copy, settled usage and no automatic retry. The final +payload check also requires the actual model user message to equal the previewed excerpt, excluding +the server-only fingerprint and card identifier. The full backend run passed 9,315 tests with 34 +existing skips. After the final payload correction, 17 observation API and 19 application tests pass; +the full solution was not repeated after that narrow correction. The competing-write tests use +deterministic repository commits, not simultaneous full HTTP model requests. One independent review +and its final scoped validation/base follow-up are CLEAN. Hosted exact-head qualification remains +the merge gate. Physical-device, live-provider usefulness and production acceptance remain separate. + ## Hosted delivery receipts (2026-09-10) +Final recovery [PR #2895](https://github.com/Chris0Jeky/Taskdeck/pull/2895) passed required run +`34458797270` at reviewed product head `f39cc6cf3`, including Windows/Ubuntu suites and browser smoke. +After unrelated CI admission tooling landed on main, its integration passed 343 focused tooling +tests, bounded base review and the complete required run `34462673996` at `d25edf2a5`. Another +base update adds portable CI tooling only; no application or workflow file changed. The PR records +the latest base qualification and final merge receipt. These successful hosted results supersede +earlier pending-hosted notes for the recovery implementation; synthetic-provider and physical-device +acceptance limits remain unchanged. + The source manifest was rechecked against the original local pack: all 20 paths, byte lengths and SHA-256 values match, with no additional or missing files. PR #2866 merged as `384d8dfaa` after its required gate. PR #2886 merged as `f00cf1d31` at 08:10 UTC after required run `34450719109` passed at reviewed head `fce97dc67`, including Ubuntu/Windows API and unit suites and final browser smoke. All prior continuation heads are included. The #2886 merge receipt has the same tree as that tested -head; integrating it into #2892 changes no runtime files. Reminder hours remain subject to their own -final exact-head hosted gate. These receipts supersede earlier pending-hosted notes for #2886. +head; integrating it into #2892 changes no runtime files. PR #2892 merged as `93eab3443` at 08:52 UTC +after required run `34453924959` passed at reviewed head `42965edde`. Its merge receipt has the same +tree as that head. These receipts supersede earlier pending-hosted notes for those PRs. ## Reminder work-hours and time zones (2026-09-10) diff --git a/frontend/taskdeck-web/src/components/workspace/GroundedObservationsPanel.vue b/frontend/taskdeck-web/src/components/workspace/GroundedObservationsPanel.vue index b84a4bcd8e..0df0080730 100644 --- a/frontend/taskdeck-web/src/components/workspace/GroundedObservationsPanel.vue +++ b/frontend/taskdeck-web/src/components/workspace/GroundedObservationsPanel.vue @@ -20,7 +20,7 @@ const notice = ref('') let generation = 0 function message(value: unknown) { const display = getErrorDisplay(value, 'Analysis could not be confirmed. Refresh insights and preview the evidence before trying again.') - return display.code === 'Conflict' ? 'The source changed. Preview current evidence before analyzing again.' : display.message + return display.message } function reset() { generation++ diff --git a/frontend/taskdeck-web/src/components/workspace/WorkspaceAttentionSettings.vue b/frontend/taskdeck-web/src/components/workspace/WorkspaceAttentionSettings.vue index 4331fca95f..a0fb45d732 100644 --- a/frontend/taskdeck-web/src/components/workspace/WorkspaceAttentionSettings.vue +++ b/frontend/taskdeck-web/src/components/workspace/WorkspaceAttentionSettings.vue @@ -9,20 +9,30 @@ const restricted = ref(false) const zone = ref('UTC') const days = ref([1, 2, 3, 4, 5]) const start = ref('09:00'); const end = ref('17:00') +let savingEnablement = false const dayOptions = [{ value: 1, name: 'Monday' }, { value: 2, name: 'Tuesday' }, { value: 3, name: 'Wednesday' }, { value: 4, name: 'Thursday' }, { value: 5, name: 'Friday' }, { value: 6, name: 'Saturday' }, { value: 0, name: 'Sunday' }] const formatTime = (minutes: number) => `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}` const minutes = (value: string) => { const [hour, minute] = value.split(':').map(Number); return hour! * 60 + minute! } watch(() => attention.settings, settings => { + // An enable-only receipt must not replace an independent, unsaved hours draft. + if (settings && savingEnablement) return + savingEnablement = false const window = settings?.window restricted.value = !!window zone.value = window?.timeZoneId ?? 'UTC' days.value = window ? dayOptions.filter(day => (window.daysMask & (1 << day.value)) !== 0).map(day => day.value) : [1, 2, 3, 4, 5] start.value = formatTime(window?.startMinute ?? 540); end.value = formatTime(window?.endMinute ?? 1020) -}, { immediate: true }) +}, { immediate: true, flush: 'sync' }) const valid = computed(() => !restricted.value || (zone.value.trim().length > 0 && days.value.length > 0 && /^\d{2}:\d{2}$/.test(start.value) && /^\d{2}:\d{2}$/.test(end.value) && start.value !== end.value)) function localZone() { zone.value = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' } +async function saveEnabled(enabled: boolean) { + if (attention.busy || !attention.settings) return + savingEnablement = true + try { await attention.save(enabled) } + finally { savingEnablement = false } +} function saveHours() { if (!attention.settings || !valid.value) return void attention.save(attention.settings.enabled, restricted.value ? { timeZoneId: zone.value.trim(), @@ -37,7 +47,7 @@ onMounted(() => { void attention.load() })

Show a quiet link to an existing question while you browse a board. At most two per UTC day, at least two hours apart across your devices. No new questions or model calls are made.

Reminders stay hidden while typing, using a dialog, working in Focus or Zen, or away from this tab.

+ @change="saveEnabled(($event.target as HTMLInputElement).checked)"> Enable occasional reminders
Reminder hours diff --git a/frontend/taskdeck-web/src/store/workspaceAttentionStore.ts b/frontend/taskdeck-web/src/store/workspaceAttentionStore.ts index 0a96246920..3de8e2eab6 100644 --- a/frontend/taskdeck-web/src/store/workspaceAttentionStore.ts +++ b/frontend/taskdeck-web/src/store/workspaceAttentionStore.ts @@ -3,6 +3,7 @@ import { defineStore } from 'pinia' import { useSessionStore } from './sessionStore' import { workspaceAttentionApi, type AttentionSettings, type AttentionWindow } from '../api/workspaceAttentionApi' import { isDemoMode } from '../utils/demoMode' +import { getErrorDisplay } from '../composables/useErrorMapper' export const useWorkspaceAttentionStore = defineStore('workspaceAttention', () => { const session = useSessionStore() @@ -20,8 +21,14 @@ export const useWorkspaceAttentionStore = defineStore('workspaceAttention', () = : window === undefined ? await workspaceAttentionApi.save(settings.value!.revision, enabled) : await workspaceAttentionApi.save(settings.value!.revision, enabled, window) if (current === generation && owner === session.userId) settings.value = next - } catch { + } catch (failure) { if (current === generation && owner === session.userId) { + const display = getErrorDisplay(failure, '') + const status = (failure as { response?: { status?: number } } | null)?.response?.status + if (enabled !== undefined && status === 400 && display.code === 'ValidationError') { + error.value = display.message + return + } settings.value = null error.value = 'Your reminder preference could not be confirmed. Reload it before continuing.' } diff --git a/frontend/taskdeck-web/src/tests/components/GroundedObservationsPanel.spec.ts b/frontend/taskdeck-web/src/tests/components/GroundedObservationsPanel.spec.ts index 08963024fe..20175060ed 100644 --- a/frontend/taskdeck-web/src/tests/components/GroundedObservationsPanel.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/GroundedObservationsPanel.spec.ts @@ -25,6 +25,18 @@ beforeEach(() => { }) describe('GroundedObservationsPanel', () => { + it('preserves the concurrent-save recovery outcome instead of claiming the source changed', async () => { + const wrapper = open(); await preview(wrapper) + const message = 'Another request changed your question results. This request saved no observations. Reload Quiet insights to read the current results. Model usage was already accounted for; analyzing again uses budget again.' + mocks.generate.mockRejectedValue({ response: { data: { errorCode: 'Conflict', message } } }) + await button(wrapper, 'Analyze this evidence with model').trigger('click'); await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe(message) + expect(wrapper.text()).not.toContain('The source changed') + expect(mocks.generate).toHaveBeenCalledOnce() + expect(wrapper.find('pre').exists()).toBe(false) + expect(wrapper.emitted('generated')).toBeUndefined() + }) + it('waits for explicit selection and preview, then submits exactly that evidence once', async () => { const wrapper = open(); await flushPromises() expect(mocks.cards).not.toHaveBeenCalled(); expect(mocks.generate).not.toHaveBeenCalled() diff --git a/frontend/taskdeck-web/src/tests/components/WorkspaceAttention.spec.ts b/frontend/taskdeck-web/src/tests/components/WorkspaceAttention.spec.ts index b5a44c9584..dc324d7009 100644 --- a/frontend/taskdeck-web/src/tests/components/WorkspaceAttention.spec.ts +++ b/frontend/taskdeck-web/src/tests/components/WorkspaceAttention.spec.ts @@ -27,6 +27,65 @@ beforeEach(() => { }) afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers(); vi.restoreAllMocks() }) describe('optional quiet reminders', () => { + it('retains the hours draft after a definite validation rejection and saves the correction once', async () => { + wrapper = mount(WorkspaceAttentionSettings); await flushPromises() + await wrapper.findAll('input[type=checkbox]')[1]!.setValue(true) + await wrapper.get('input[type=text]').setValue('Not/AZone') + await wrapper.findAll('input[type=time]')[0]!.setValue('22:00') + await wrapper.findAll('input[type=time]')[1]!.setValue('02:00') + const message = 'Choose a valid IANA time zone and reminder hours.' + vi.mocked(workspaceAttentionApi.save).mockRejectedValueOnce({ response: { status: 400, data: { errorCode: 'ValidationError', message } } }) + await wrapper.get('form').trigger('submit'); await flushPromises() + expect(wrapper.find('form').exists()).toBe(true) + expect((wrapper.get('input[type=text]').element as HTMLInputElement).value).toBe('Not/AZone') + expect(wrapper.text()).toContain(message) + expect(useWorkspaceAttentionStore().settings?.revision).toBe(1) + expect(workspaceAttentionApi.save).toHaveBeenCalledTimes(1) + await wrapper.get('input[type=text]').setValue('UTC') + const window = { timeZoneId: 'UTC', daysMask: 62, startMinute: 1320, endMinute: 120 } + vi.mocked(workspaceAttentionApi.save).mockResolvedValueOnce({ ...settings, revision: 2, window }) + await wrapper.get('form').trigger('submit'); await flushPromises() + expect(workspaceAttentionApi.save).toHaveBeenCalledTimes(2) + expect(workspaceAttentionApi.save).toHaveBeenLastCalledWith(1, true, window) + }) + + it.each([true, false])('preserves an hours draft across an enable-only save to %s', async enabled => { + vi.mocked(workspaceAttentionApi.get).mockResolvedValue({ ...settings, enabled: !enabled }) + wrapper = mount(WorkspaceAttentionSettings); await flushPromises() + await wrapper.findAll('input[type=checkbox]')[1]!.setValue(true) + await wrapper.get('input[type=text]').setValue('America/New_York') + await wrapper.findAll('input[type=checkbox]')[4]!.setValue(false) + await wrapper.findAll('input[type=checkbox]')[7]!.setValue(true) + await wrapper.findAll('input[type=time]')[0]!.setValue('22:00') + await wrapper.findAll('input[type=time]')[1]!.setValue('02:00') + vi.mocked(workspaceAttentionApi.save).mockResolvedValue({ ...settings, enabled, revision: 2 }) + await wrapper.get('input').setValue(enabled); await flushPromises() + expect(workspaceAttentionApi.save).toHaveBeenCalledExactlyOnceWith(1, enabled) + expect((wrapper.findAll('input[type=checkbox]')[1]!.element as HTMLInputElement).checked).toBe(true) + const window = { timeZoneId: 'America/New_York', daysMask: 118, startMinute: 1320, endMinute: 120 } + vi.mocked(workspaceAttentionApi.save).mockResolvedValue({ ...settings, enabled, revision: 3, window }) + await wrapper.get('form').trigger('submit'); await flushPromises() + expect(workspaceAttentionApi.save).toHaveBeenCalledTimes(2) + expect(workspaceAttentionApi.save).toHaveBeenLastCalledWith(2, enabled, window) + }) + + it('clears an hours draft on account change while ignoring the old enable receipt', async () => { + wrapper = mount(WorkspaceAttentionSettings); await flushPromises() + await wrapper.findAll('input[type=checkbox]')[1]!.setValue(true) + await wrapper.get('input[type=text]').setValue('America/New_York') + let complete!: (value: typeof settings) => void + vi.mocked(workspaceAttentionApi.save).mockReturnValue(new Promise(resolve => { complete = resolve })) + await wrapper.get('input').setValue(false); await flushPromises() + session.userId = 'different-owner'; await flushPromises() + expect(wrapper.find('form').exists()).toBe(false) + const window = { timeZoneId: 'Europe/London', daysMask: 62, startMinute: 540, endMinute: 1020 } + vi.mocked(workspaceAttentionApi.get).mockResolvedValue({ ...settings, window }) + await wrapper.get('button').trigger('click'); await flushPromises() + complete({ ...settings, enabled: false, revision: 2 }); await flushPromises() + expect((wrapper.get('input[type=text]').element as HTMLInputElement).value).toBe('Europe/London') + expect(useWorkspaceAttentionStore().settings?.enabled).toBe(true) + }) + it('saves an explicit weekly window and disables controls until its receipt arrives', async () => { wrapper = mount(WorkspaceAttentionSettings); await flushPromises() const restrict = wrapper.findAll('input[type=checkbox]')[1]! diff --git a/frontend/taskdeck-web/tests/e2e/workspace-attention.spec.ts b/frontend/taskdeck-web/tests/e2e/workspace-attention.spec.ts index 88e686ddc0..ea2413bd15 100644 --- a/frontend/taskdeck-web/tests/e2e/workspace-attention.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/workspace-attention.spec.ts @@ -64,6 +64,23 @@ test('reminder hours persist across experiences and recover a concurrent setting for (const day of ['Tuesday', 'Wednesday', 'Thursday', 'Friday']) await settings.getByRole('checkbox', { name: day, exact: true }).uncheck() await settings.getByLabel('Start time', { exact: true }).fill('22:00') await settings.getByLabel('End time', { exact: true }).fill('02:00') + await settings.getByLabel('Time zone', { exact: true }).fill('Not/AZone') + const invalid = page.waitForResponse(response => response.url().endsWith('/workspace-attention') && response.request().method() === 'PUT') + await settings.getByRole('button', { name: 'Save reminder hours', exact: true }).click() + expect((await invalid).status()).toBe(400) + await expect(settings.getByLabel('Time zone', { exact: true })).toHaveValue('Not/AZone') + await expect(settings.getByLabel('Start time', { exact: true })).toHaveValue('22:00') + await expect(settings.getByLabel('End time', { exact: true })).toHaveValue('02:00') + await settings.getByLabel('Time zone', { exact: true }).fill('Europe/London') + for (const enabled of [true, false]) { + const toggled = page.waitForResponse(response => response.url().endsWith('/workspace-attention') && response.request().method() === 'PUT') + await settings.getByRole('checkbox', { name: 'Enable occasional reminders', exact: true }).setChecked(enabled) + const toggleReceipt = await toggled; expect(toggleReceipt.ok()).toBe(true) + expect((await toggleReceipt.json()).window).toBeNull() + await expect(settings.getByLabel('Time zone', { exact: true })).toHaveValue('Europe/London') + await expect(settings.getByLabel('Start time', { exact: true })).toHaveValue('22:00') + await expect(settings.getByLabel('End time', { exact: true })).toHaveValue('02:00') + } const saved = page.waitForResponse(response => response.url().endsWith('/workspace-attention') && response.request().method() === 'PUT') await settings.getByRole('button', { name: 'Save reminder hours', exact: true }).click() const receipt = await saved; expect(receipt.ok()).toBe(true)