Addresses multi-agent review findings on the uploaded-plugin nodeExecution
consent gate:
- id validation: use an allowlist (/^[A-Za-z0-9][A-Za-z0-9._-]*$/) instead of a
Unicode denylist, closing bidi/zero-width/homoglyph dialog-anchor spoofing the
range list missed (U+061C, U+2060, U+3164, fullwidth chars); strip all Unicode
control+format chars from the self-declared display name/version.
- never upgrade trust: describeVerifiedBuiltInDialog returns null on any imperfect
on-disk verification (id mismatch, missing permission, unreadable manifest) and
the grant handler falls back to the unverified dialog, so a colliding uploaded id
can never borrow a built-in's verified dialog.
- reserve the gitea/linear/trello/azure-devops issue-provider bundled ids (they had
drifted out of BUNDLED_PLUGIN_IDS, leaving an impersonation gap) and guard the
BUNDLED_PLUGIN_PATHS subset-of BUNDLED_PLUGIN_IDS invariant with a node test.
- key the revoke and exec IPC handlers through the same assertSafePluginId as the
grant handler so the "revoke by id on teardown/re-upload" guarantee can't drift.
- clear the session nodeExecution denial when a plugin is uninstalled, so a fresh
re-upload of the same id is prompted again rather than silently failing closed.
- de-duplicate the PluginNodeExecutionElectronApi interface into a single
electron/shared-with-frontend model (was copied byte-identically in two files).
Multi-agent review of the Phase 1 gate found the uploaded plugin id —
used as a path component in the bundled-manifest existsSync probe — was
not rejecting path separators or dot-segments. Impact was bounded (the
verified-builtin branch re-validates with the strict kebab regex before
any read, so no code exec / file read / dialog spoof), but `..` / `/`
left a filesystem-existence oracle and rendered misleadingly as the
dialog "Plugin ID". assertSafePluginId now rejects `/`, `\`, `.`, `..`.
Also from review: factor the shared Allow/Deny dialog shell, and correct
the denied-cache comment (the existing token short-circuit handles the
multi-call-site case; the cache only makes a denial sticky across a later
non-interactive grant re-entry). Documents the uploaded-id constraints.
Refs #8512
Re-open the nodeExecution permission for uploaded/community plugins
(previously built-in only, #8205) behind the existing main-process
consent dialog. Phase 1 of #8512; unblocks the Super Productivity MCP
plugin (discussion #8385).
- Main process sanitizes the attacker-controlled plugin id and the
self-declared name/version before they reach the consent dialog or
the grant map (control/bidi/whitespace rejected, length-capped).
- Bundled vs uploaded is decided by the on-disk manifest, never a
renderer-supplied flag, so uploaded code can't borrow a built-in
plugin's verified name.
- Uploaded-plugin dialog anchors on the validated id, flags the plugin
as unverified third-party with full machine access / no sandbox, and
defaults to Deny.
- Revoke is main-authoritative by (pluginId, webContents) so a re-upload
reusing an id can't inherit a live session grant.
- Consent stays session-scoped: an in-memory, never-synced denied set
prevents re-prompt storms; deny keeps the plugin enabled but fails
node calls closed until re-enable or restart.
Refs #8512#8385
#8542 made orphan detection O(n), but the reconciliation that follows still
ran per-orphan `ids.includes()` + array `.filter()` rebuilds, plus per-orphan
project-list/inbox `[...arr, id]` spreads — so a corruption that orphans many
tasks (the exact shape the restore path exists to handle) stayed O(n²) and
could still hang/crash the restore on a large store.
- _moveArchivedSubTasksToUnarchivedParents / _moveUnArchivedSubTasksToArchivedParents:
use snapshot Sets (kept in sync with the pushes) for membership and defer
archive/main id removals to a single post-loop filter.
- _addOrphanedTasksToProjectLists / _addInboxProjectIdIfNecessary: accumulate
additions and splice each list with one spread instead of per-item.
Output is byte-identical to before (same ids, same order), so the REPAIR op is
unchanged. Adds a behavior-at-scale spec covering all three reconciled sites.
* fix(electron): use file:/// for Windows clipboard image paths
On Windows, absolute paths start with a drive letter (e.g. C:/...) so
ile:// + path yields ile://C:/..., which fails the canonical
local-file security check that requires ile:///. This caused
clipboard-pasted and screenshot images to render as broken icons in
Markdown notes and attachment thumbnails.
Introduce pathToFileUrl() helper that emits ile:///path on all
platforms (on Unix paths already start with /, so file:// + /path
is unchanged).
* fix(electron): resolve clipboard image file:// paths to blob URLs on Windows
On Windows, clipboard image paths use a drive-letter prefix (C:/...) so
the previous \ile://\\ template produced \ile://C:/...\ instead
of the canonical \ile:///C:/...\. This caused two distinct rendering
failures:
1. The three-slash check in isExternalUrlSchemeAllowed blocked the URL
entirely, so images inserted via Ctrl+V or screenshot paste were shown
as broken icons both in Markdown notes and in attachment thumbnails.
2. Even with the correct three-slash form, Angular's DomSanitizer strips
the file: scheme from <img src> bindings (SecurityContext.URL only
allows http/https/blob/data), so the image never rendered regardless
of path correctness.
Changes:
- Add pathToFileUrl() helper that emits file:///path on all platforms
(Unix paths already start with /, so behaviour is unchanged there).
- Extend resolveMarkdownImages() to also convert file:/// clipboard-image
paths to blob: URLs before the markdown is handed to the sanitizer.
- Add resolveClipboardImageUrl() public method that handles both
indexeddb:// and file:/// clipboard-image URLs uniformly.
- Update task-attachment-list to use resolveClipboardImageUrl() and
extend _isResolvableClipboardUrl() to cover file:/// clipboard-image
paths so attachment thumbnails also resolve to blob: before rendering.
- When pasting an image file from the file explorer (clipboardData.files),
copy it into the clipboard-images directory instead of referencing the
original path directly, so the resulting URL is always within our
controlled directory and can be resolved to blob:.
* fix(electron): address review feedback on clipboard image paths
- percent-encode spaces in pathToFileUrl so markdown parser does not
split URLs at whitespace (affects Windows usernames like "John Doe")
- correct misleading comments: the blocker is Chromium's cross-origin
http://localhost→file:// restriction, not Angular DomSanitizer
- fix blob URL leak in _deleteImageElectron: revoke and delete the
electron-file:<id> cache entry instead of the wrong bare id key
- add clipboard-image.service.spec.ts covering pathToFileUrl (Unix,
Windows, backslashes, spaces) and resolveClipboardImageUrl edge cases
Electron 32+ removed the non-standard File.path property, but drop
handling still read it. On Electron 41 it is undefined, so dropped
file attachments stored only the bare file name and 'open' silently
failed because shell.openPath cannot resolve a relative path.
Recover the absolute path via window.ea.getPathForFile (the official
webUtils replacement, already used for clipboard images), falling back
to file.name in the browser.
Closes#8553
work-view.component.spec overrides selectTodayStr and
selectStartOfNextDayDiffMs via overrideSelector (setResult), which mutates
the global selector singletons. NgRx MockStore has no automatic per-spec
teardown, so the frozen today/offset values leaked into later specs under
Jasmine's randomized order, intermittently breaking their 'today'
assertions (planner.selectors, task.selectors).
Hoist the MockStore to the outer describe and resetSelectors() in a shared
afterEach so the overrides are released at the source, instead of clearing
them defensively per victim spec.
work-view.component.spec overrides selectTodayStr and
selectStartOfNextDayDiffMs via overrideSelector (setResult), which
persists across specs and is not cleared by release(). The offset-based
selectOverdueTasks tests read those leaked values under the LA-timezone
include ordering, making Feb-15 tasks look overdue (Expected 1 to be 0).
Extend the existing defensive clearResult()/release() block to cover
selectTodayStr and selectStartOfNextDayDiffMs.
Multi-review finding: the UI cooldown was keyed by taskId alone, so two real
edge cases hid a genuinely-due reminder for up to 5 min:
- Reschedule within the window: passively dismissing a reminder, then
rescheduling that task to fire in <5 min, suppressed the new reminder until
the old cooldown lapsed (the previous comment's "fires past the cooldown
anyway" was simply wrong for near-future reschedules).
- Schedule vs deadline collision: a scheduled reminder's cooldown also
suppressed that same task's deadline reminder.
Key the cooldown per reminder OCCURRENCE (taskId + remindAt) instead. A
reschedule yields a fresh remindAt -> new key -> not suppressed, and schedule
vs deadline reminders never share a cooldown. The dialog now records the
remindAt of each shown reminder so the destroy-time cooldown targets the exact
occurrence. Add a reschedule regression test; drop the redundant boundary test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An overdue scheduled reminder kept re-opening its modal every ~10s (the
reminder worker's check interval) and on every app resume. Dismissing the
dialog via backdrop / Escape / Android back runs none of the clear logic
(ngOnDestroy only clears deadline reminders), so the worker re-emitted the
still-active reminder and the module re-opened the modal indefinitely. On
mobile this reads as a fully frozen app where no controls respond.
Add a short in-memory UI cooldown: on a passive dismiss, scheduled reminders
are suppressed from re-opening for 5 minutes. The reminder itself stays
active — it re-nudges after the cooldown and on cold start — and explicit
actions (snooze / done / add-to-today / reschedule) are unaffected. The
cooldown is presentation-only; no synced state is touched.
Related to #8551 (a dropped done-from-notification leaves a task overdue and
active), the common way to reach this state on Android.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The laterTodayCalendarEvents computed reads Date.now(), which is not
reactive, so the now/end-of-today window only re-evaluated on a calendar
poll (up to every 2h for iCal) — a started event could linger in 'Later
Today'. Add a coarse refresh tick (mirrors ScheduleService) so events
drop out shortly after they start.
De-duplicate the all-day calendar-event classification (isAllDay flag or
>= 24h duration) that was copied between the planner selector and the new
work-view later-today util. Extract it to schedule.model.ts so both views
classify events identically and a future provider-edge-case fix lands in
one place.
Surface timed calendar events occurring later today in the Today view's
'Later Today' section, rendered with the planner's calendar-event outline
component (read-only card + context menu). The section now appears when
there are later-today tasks or events, and its count reflects both.
Events are filtered by a pure util (timed events starting between now and
end-of-today, honoring the start-of-next-day offset; all-day excluded),
mirroring selectLaterTodayTasksWithSubTasks' window for tasks.
The scheduled master E2E (run 27927390789, SuperSync 3/6 shard) flaked on
"Bidirectional sync works after encryption change via import". The stuck
overlay was the "Decryption Failed" dialog (dialog-handle-decrypt-error):
it fires asynchronously after a re-sync, and the setupSuperSync dialog
loop only handles it within its bounded rounds. A late re-fire was never
dismissed, so the final one-shot expect(...).toHaveCount(0) watched the
disableClose dialog for the full 15s and timed out.
Replace the one-shot late-dialog wait + close assertion with a converging
expect.toPass() poll that dismisses whichever late, non-self-closing dialog
is present each round (enable-encryption or decrypt-error), then re-checks
the overlay is empty — handling appearances that land early, late, or never.
Extract the decrypt-error handling into a shared _handleDecryptErrorDialog
helper reused by the loop and the drain.
Verified via prettier/eslint/tsc; not run live (the @supersync docker e2e
can't reach the server from the dev sandbox).
A task marked Done from a system notification while the app was closed
reappeared as not-done on open.
On cold start the queued notification actions are delivered before the
NgRx store is hydrated from persistence: Android replays them from
ReplaySubjects the moment we subscribe, and the iOS Capacitor listener
fires into a plain Subject. The handlers ran ungated in the module
constructor, so getByIdOnce$() read an empty store and returned
undefined. The done/tap guard `if (\!task || task.isDone)` treats
undefined as "already completed" and returns without setDone — and the
native queue is read-once (getAndClear), so the action was lost.
Route all Android (tap/done/snooze) and iOS (action$) handlers through a
new _handleAfterDataLoaded helper that subscribes immediately (so no
event from a non-buffering Subject is missed) but defers handling of each
event until afterInitialSyncDoneAndDataLoadedInitially$ emits — the same
gate the reminder dialog already uses. Warm resume is unaffected
(shareReplay replays synchronously).
Add unit tests covering the cold-start defer, warm-resume passthrough,
and event ordering.
* fix(android): size fullscreen markdown/notes dialog above the keyboard (#8508)
The fullscreen markdown editor (project & task notes) is position:fixed;
height:100% but its keyboard rule subtracted --keyboard-overlay-offset, which
is set only on iOS. On Android the rule was a no-op, so with the IME open the
dialog kept full height (content behind the keyboard) or inherited the squashed
sliver, leaving the toolbar + textarea + Close/Save mashed to the top.
Use the resize-detecting --keyboard-height for the Android/mobile-web case
(0 once the window resized, the obscured amount otherwise), mirroring the
add-task bar and .app-container; keep the iOS --keyboard-overlay-offset path as
a second, source-order-later rule (equal specificity, wins on iOS). On a device
that resizes (--keyboard-height == 0) it is identical to the old rule, so it is
never worse than before; it fixes the API >= 30 no-resize/VisualViewport-shrink
case and composes with the SDK < 30 native fix (#8528).
* fix(android): inset the header below the status bar on API < 30 (#8508)
Under enforced edge-to-edge (targetSdk 36) the WebView extends under the status
bar, but on the API < 30 WebView `env(safe-area-inset-top)` resolves to 0 (old
WebViews map only display cutouts into safe-area insets, not the status bar), so
`--safe-area-top` was 0 and the web header overlapped the status bar on Android 9.
The web side cannot tell "edge-to-edge under the status bar" from "already
natively inset" (env() is 0 in both), so a web-only fallback would double-count.
Measure the overlap natively instead: in the existing keyboard layout listener
(gated SDK < 30) compute max(0, rect.top - webViewTopOnScreen) — visible-frame
top (status-bar height, reliable on API 28) minus the WebView's on-screen top (0
when edge-to-edge, == status-bar height once inset) — and publish it as the
`--android-status-bar-overlap` CSS var (physical px -> CSS px, deduped). The web
folds it in with max(env(safe-area-inset-top, 0px), var(--android-status-bar-
overlap, 0px)): never a sum so it can't double-count, and on API >= 30 the var is
never set so `max(env, 0) = env` keeps the verified behavior. JS readers still
parse the token to 0, preserving the #8283 overlay scoping.
Needs on-device validation on API < 30 and a no-op check on API >= 30.
* fix(android): stop double-counting safe-area-top in note dialog height (#8508)
The fullscreen note/markdown dialog subtracted --safe-area-top from its
keyboard-open height while ALSO applying padding-top: --safe-area-top. Since
:host is border-box (global * { box-sizing: border-box }), the padding is already
inside height: 100%, so subtracting --safe-area-top again double-removed the top
inset and left a --safe-area-top-sized gap between the Close/Save controls and
the keyboard.
Invisible while --safe-area-top was 0 on API < 30; it surfaced once the
status-bar fix made it non-zero (and was latent on API >= 30 where env() already
gave a non-zero value). Drop the - --safe-area-top term from the Android rule so
the controls sit flush above the keyboard while the toolbar stays below the
status bar. iOS keeps its own override (different keyboard runtime, unverified on
device) and is flagged for separate checking.
* fix(android): re-publish status-bar overlap after web reload (#8508)
Review follow-ups to the #8508 keyboard/status-bar fixes:
- The native --android-status-bar-overlap lives only as an inline style on
the document, so a web-side window.location.reload() (language change, PWA
update, sync-conflict recovery) wipes it while the dedupe field survives on
the Activity -> the unchanged value is skipped and the header overlaps the
status bar again on the WebView < 140 / API < 30 tail. Reset
lastStatusBarOverlapCssPx in flushPendingShareIntent() (runs on every
frontend (re)load) so the next layout pass re-publishes it.
- Dialog keyboard rule: scope the Android rule with :not(.isIOS). iOS carries
both isNativeMobile and isIOS and sets --keyboard-height non-zero, so the two
equal-specificity rules both matched and iOS correctness depended on source
order; they are now mutually exclusive and order-independent.
- Declare --android-status-bar-overlap: 0px in :root for discoverability,
alongside the other dynamic vars.
* fix: ignore clicks with active selections/drags to prevent entering edit mode in inline markdown and enable Electron context menu (fix#8407)
* fix(notes): keep checklist glyph out of copied selection
The view-mode selection support makes the Material Icons checkbox
ligature selectable, so dragging across a checklist would copy the
literal glyph name (e.g. check_box_outline_blank) into the clipboard.
Mark the glyph user-select:none at the source while keeping its label
copyable.
* refactor(notes): drop out-of-scope Electron context menu (#8407)
The renderer-side selection-safe click guard fully resolves#8407: text
is selectable and Ctrl/Cmd+C copies in both web and desktop. The global
webContents context-menu handler was an unrequested, app-wide addition
(it governs every input and view, not just the note preview) shipped
with hardcoded English labels that override Electron's OS-localized role
labels. If native right-click menus are wanted app-wide, that belongs in
its own deliberate PR, not bundled into the notes fix.
* refactor(notes): tidy drag-distance check in note click guard
Replace the manual dx²+dy²+sqrt with Math.hypot and pull the 5px
magic number into a named DRAG_THRESHOLD_PX constant. Behaviour is
unchanged; this is purely readability.
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* docs(sync): point Nextcloud user-ID lookup at the WebDAV URL (#7617)
The field hint, the 404 test-connection message, and the wiki all told
users to find their user ID under 'Settings -> Personal info' / 'your
Files URL'. Both are unreliable: Personal info does not clearly surface
the uid, and a folder's address bar shows a folder ID, not the user ID
(reporter followed it and got a folder ID). Redirect all three to the
authoritative source: Files -> settings gear (bottom-left) -> the WebDAV
URL '.../remote.php/dav/files/<user-id>/'.
* feat(sync): auto-detect Nextcloud user ID via OCS (#7617)
The Nextcloud WebDAV files path needs the account's internal user ID,
which differs from the email/login name people enter and is awkward to
find by hand — the root cause of the recurring '404 / connection test
failed' reports. Add a 'Detect user ID' button to the Nextcloud sync
config that asks the server for it.
- packages/sync-providers: discoverNextcloudUserId() calls the OCS
endpoint /ocs/v2.php/cloud/user (OCS-APIRequest header) authenticated
with login + app password and returns ocs.data.id. A 401 reports bad
credentials (cleanly distinct from the 404 wrong-user-id path); a 200
that isn't an OCS payload reports 'not a Nextcloud/OCS server'. A
missing/wrong URL scheme is refused up front (matches the provider's
own _cfgOrError check) so credentials never hit a schemeless host.
- Dialog: button fills the Username field with the detected ID. To keep
auth working, if 'Login name' was empty and the user had typed their
login into 'Username', that login is preserved into 'Login name'
before Username is overwritten with the ID. Result handling split into
_applyDetectedUserIdResult for unit-testability.
- Additive and optional: generic WebDAV and the save/sync path are
untouched, no synced data shapes change.
- Tests: 7 package specs (success, trailing-slash, login fallback, 401,
non-OCS 200, missing id, scheme guard) + 6 dialog specs (login guard,
fill+confirm, 3 login-preservation cases, failure).
* feat(azure-devops): add optional WIQL override for auto backlog import
Adds an optional autoImportWiql config field to the Azure DevOps issue
provider plugin. When set, it fully replaces the generated auto-import
backlog query, so the user controls scope, state filtering and ordering
(e.g. to filter by iteration path, area path or work item type, or to
match custom done-state names). When empty, behavior is unchanged from
today's scope-based query, so it is fully backward compatible.
This mirrors the Jira provider's autoAddBacklogJqlQuery (a full query the
user owns) rather than appending a fragment, which can only narrow the
hard-coded English done-state exclusion and cannot accept a real exported
WIQL Select..From..Where statement.
Adds a vitest suite covering the default scopes, quote escaping, the
verbatim override and the blank-fallback, wires the plugin into the
plugin-tests CI matrix, and updates the issue integration comparison wiki.
Closes#7674
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DY1ESWymmU9x9ykLNaRHGH
* test(azure-devops): use node test env, drop unneeded jsdom dependency
The plugin tests exercise no DOM API, so vitest's default node
environment is sufficient (matching the clickup and google-calendar
provider plugins). Removing jsdom prunes ~530 lines of transitive
devDependencies from the lockfile.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* fix(tasks): add visible keyboard focus ring to subtasks (#8520)
* fix(theme): restore subtask keyboard focus ring in liquid-glass theme
* fix(theme): guard liquid-glass subtask focus ring to non-touch devices
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
dataRepair runs on the backup-restore path whenever a backup fails typia
validation. Several membership checks used Array.includes/Array.filter
inside per-entity loops, making repair O(n^2) in task/archive size:
~20s+ of single-threaded CPU on a large store, which can leave the
restore dialog unresponsive and crash the renderer (OOM/coredump).
Replace those scans with Set membership in the hottest functions (the
three orphan-subtask list filters, _removeDuplicatesFromArchive,
_removeMissingTasksFromListsOrRestoreFromArchive,
_addOrphanedTasksToProjectLists). Semantics are preserved exactly — the
Sets are snapshots of arrays that are not mutated while in use.
Measured dataRepair on a worst-case archive: 80k tasks 5887ms -> 212ms;
160k extrapolated ~23s -> 452ms. All 56 existing data-repair tests pass.
Likely cause of #8540 (pending reporter backup-size confirmation).
* feat(android): migrate edge-to-edge to built-in SystemBars
Replace @capawesome/capacitor-android-edge-to-edge-support with Capacitor 8's
built-in SystemBars (insetsHandling: 'css'). SystemBars handles edge-to-edge
insets + IME padding on WebView >= 140 / API >= 35; the WebView < 140 / API < 35
tail is covered by env() fallback plus a native keyboard shim
(adjustWebViewHeightForKeyboardBelowApi30), gated to WebView < 140 so it does
not fight SystemBars' own IME padding.
- config: drop EdgeToEdge plugin config + includePlugins entry + dep; remove the
plugin from the generated gradle registration
- theme: stop JS-writing --safe-area-inset-* on Android (SystemBars owns them;
the SCSS env() fallback preserves the #8283 top fix); bars are now transparent
with the theme color painted behind them via setWebViewBackgroundColor, since
SystemBars has no bar-color API
- StartupOverlayManager: derive the overlay inset from the system-bar insets
instead of the removed plugin's WebView margin
Spike: TypeScript verified (tsc clean, lint clean). Kotlin and on-device
behavior NOT yet validated (gradle unavailable in this environment). Requires
`npm install` + `npx cap sync android` and the device matrix in
docs/plans/2026-06-22-android-systembars-migration-corrected.md before merge.
* build(deps): drop @capawesome edge-to-edge from lockfile
Reconcile package-lock.json with the package.json change in the edge-to-edge ->
SystemBars migration (removes only the @capawesome/capacitor-android-edge-to-edge-
support entry). Generated by `npm install`.
* docs(android): clarify SystemBars band model from implementation review
Comment-only + plan-doc follow-up to the multi-agent review of the migration:
- Correct the band model in the inset comments: SystemBars *injects*
--safe-area-inset-* only on API >= 35; WebView >= 140 is native env()
passthrough (no injection below API 35). The SCSS var(..., env()) fallback
covers every band. (capacitor.config.ts, global-theme.service.ts)
- Fix a stale comment in StartupOverlayManager.show() that still described the
removed @capawesome plugin insetting the WebView.
- Record the device-validation-only findings in the plan doc (API>=35/WebView<140
double-count corner, env vs var consumer split, API 30-34/WebView<140 IME
owner, CDK overlay top shift) so they are checked on the matrix, not blind-fixed
(a blind shim extension would risk re-creating #8508).
No behavior change.
* refactor(tasks): restructure reminder dialog footer into primary + overflow
The footer showed up to four visually identical stroked buttons (Snooze,
Done, Add to Today, Start) with no hierarchy. Reduce to a single filled
primary action plus a Snooze menu and an overflow (kebab) menu:
- Primary: Start (single) / Schedule for today (multiple)
- Snooze button keeps the quick-defer menu (10/30/60 min, tomorrow)
- New overflow menu holds the rest (Done/Complete, Add to Today, edit,
unschedule, dismiss-keep-today)
All existing actions remain available; only their grouping changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* refactor(tasks): add dropdown caret to reminder snooze button
The Snooze button opens a menu but had no visible affordance signalling
that. Add a trailing arrow_drop_down icon (native Material iconPositionEnd
slot) so the menu trigger reads as such, surfaced by the review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* test(reminders): open overflow menu to reach Done in deadline specs
The reminder dialog's "Done" action moved from a footer button into the
new overflow ("More actions") menu, so the two deadline e2e specs must
open that menu before clicking Done.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* refactor(tasks): refine reminder footer hierarchy after UX review
Follow-up to the footer restructure, addressing review findings:
- Surface a one-tap "Done" (check) icon for single reminders, so the most
common reminder response isn't two taps deep in the overflow menu. Left
out for the multi-task case, where a one-tap complete-all is a footgun
and per-row checks already exist.
- Make the primary action deadline-aware: deadline reminders now default
to the safe "Add to Today" instead of "Start" (which sets the current
task and reorders Today as a side effect). Start moves into the overflow
for deadlines; scheduled reminders keep Start as primary.
- Move "Edit (reschedule)" into the Snooze ("when") menu so all
time-deferral actions are grouped and the overflow holds pure
dispositions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* test(reminders): click one-tap Done icon in deadline specs
The single-task reminder footer now surfaces "Done" as a one-tap icon
button (aria-label "Mark as done"), so the deadline specs click it
directly instead of opening the overflow menu.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* refactor(tasks): put primary reminder action on the right, Done in menu
Per UX feedback:
- Reverse the footer button order so the filled primary action is always
on the right (overflow on the left, Snooze in the middle).
- Move "Done" back into the overflow menu instead of surfacing it as a
standalone icon button.
Update the deadline e2e specs to open the overflow menu before clicking
Done accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* feat(tasks): allow dismissing reminder dialog + snooze split-button
Three reminder-dialog improvements:
- Click-away to dismiss: backdrop click / Escape now close the dialog and
dismiss the reminders (clearing the reminder while keeping the task and
its schedule). disableClose is kept and the events are handled in the
dialog, so the worker does not immediately re-open it in a loop.
- Snooze split-button: a single click snoozes 10 min (the common default,
previously a hidden double-click) and a joined dropdown caret opens the
full options menu. Uses a new shared .g-split-btn style.
- Overflow button: replace the lone vertical "three dots" icon with a
low-emphasis labeled "More" button, giving a clean emphasis ramp
(More < Snooze < primary) with the primary kept on the right.
Updates the deadline e2e specs and wiki for the new dismiss behavior, and
adds unit tests for the backdrop/Escape dismissal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* fix(tasks): make reminder dialog close on backdrop/Escape
Per product decision, clicking the backdrop or pressing Escape now simply
closes the reminder dialog (drop disableClose) rather than dismissing the
reminders. Deadline reminders are still cleared on close (avoiding the 10s
re-fire loop); scheduled reminders stay active and the worker re-shows them
until the user acts on them.
Reverts the earlier intercept-and-dismiss approach (and its unit tests);
updates the wiki accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* feat(tasks): add split-button UI component, restructure reminder footer
Add a reusable <split-button> UI component (src/app/ui/split-button): a
primary default action joined flush to a compact, centered overflow trigger
that opens a passed-in menu. The joined treatment lives in the shared style
layer (styles/components/split-button.scss) and now renders with no gap
between the two halves and a properly centered trigger icon.
Use it in the reminder dialog footer: limit the footer to two actions —
"Snooze 10m" (showing the snooze duration) and the primary CTA (Start /
Add to Today) — and fold the former "More actions" and snooze-options menus
into a single overflow menu opened by the icon button right of snooze.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* fix(tasks): fuse split-button halves and update reminder dialog test
The split-button trigger was pushed 8px away from the default action by
Angular Material's `.mat-mdc-dialog-actions .mat-mdc-button-base +
.mat-mdc-button-base` rule (specificity 0,3,0), which outweighed the
joining `margin-left: -1px`. Match Material's selector hooks so the
negative margin wins and the two halves render flush inside dialogs.
Also drop the stale `disableClose: true` assertion from the reminder
module spec to match the now-dismissable dialog (fixes failing CI).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* fix(tasks): clarify reminder dialog "add to today" button label
The multi-task primary button said "Schedule for today", which is both long
and misleading: those tasks are already scheduled — the action drops their
time and keeps them on Today (all day), or for deadlines adds them to Today
while keeping the deadline date.
Key the label on deadline vs schedule instead of single vs multiple:
- all deadlines -> "Add to Today" (added to today, deadline preserved)
- scheduled/mixed -> "Today" (already scheduled; kept on today, all day)
Reuses the existing TODAY_TAG_TITLE string; SCHEDULE_FOR_TODAY remains for
the per-row tooltips.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* fix(tasks): distinguish deadline vs scheduled in reminder today-button
After weighing options, label the reminder dialog's primary today-action by
what it actually does in each case:
- all deadlines -> "Add to Today" (task is added to Today; deadline kept)
- scheduled/mixed -> "Keep in Today" (already scheduled; kept on Today, all
day, with the specific time dropped)
This replaces the bare, verb-less "Today" with accurate wording consistent
with the dialog's existing "keep in Today" vocabulary. Adds the
KEEP_IN_TODAY en string (other locales fall back to English via fallbackLang).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE
* chore(reminders): drop unused SNOOZE_OPTIONS string, regenerate t.const.ts
The reminder-dialog footer trigger uses MORE_ACTIONS, not SNOOZE_OPTIONS,
which was a dead key from an earlier iteration. Remove it and regenerate
t.const.ts through prettier so the file is back to the formatted form
(the prior commit had committed raw generator output, churning ~760 lines).
* refactor(ui): trim unused split-button inputs, add unit spec
The split-button's only consumer never overrides color or triggerIcon, so
drop both inputs (and the deprecated ThemePalette type) and inline the
defaults. Add a unit spec covering content projection, mainClick, menu
wiring, disabled state, and the trigger aria-label.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* cleanup: remove unused latestSnapshotSeq from HTTP response and client types
latestSnapshotSeq was computed on every download but never read by any
client code. sibling snapshotVectorClock IS consumed — only this field
is dead. The server still computes it internally for the snapshot
optimization logic, but it no longer serializes it in the response.
Removed from:
- DownloadOpsResponse type and response object (sync.routes.ts)
- Zod schema (supersync-http-contract.ts) + associated test
- OpDownloadResponseBase interface (sync-providers)
- Client validator test + stale comment references
- Server integration test assertions
* cleanup: remove deprecated CONFLICT_STALE error code
Server never emits CONFLICT_STALE (fully migrated to
CONFLICT_SUPERSEDED).
* cleanup: remove unused SyncConfig fields (maxOpsPerUpload, downloadLimit, downloadRateLimit)
These three config fields were declared and defaulted but never read by
any code. Routes hardcode their own limits:
- maxOpsPerUpload: gate is MAX_OPS_PER_BATCH constant
(sync.routes.payload.ts)
- downloadLimit: hardcoded Math.min(limit, 1000) in sync.routes.ts
- downloadRateLimit: hardcoded max:200 in route rateLimit config
* cleanup: remove dead/drifted duplicate types from sync.types.ts
Removed 6 types with zero references repo-wide:
- SnapshotResponse: orphaned after previous PR removed its importers
- UploadSnapshotRequest: zero uses, missing 7 fields vs actual Zod
schema
- RestorePointType: zero uses, included dead 'DAILY_BOUNDARY' value
- RestorePoint: zero uses (live version in snapshot.service.ts)
- RestorePointsResponse: zero uses
- RestoreSnapshotResponse: zero uses
focusRelatedTaskOrNext() called .focus() (default preventScroll:false) after the context menu closed. When the action relocated the task (e.g. Overdue -> Today), the native scroll-into-view yanked the work-view to the moved task's new position at the top of the list. This broke triaging several overdue tasks in a row on touch, where the only path to "add to today" is the swipe context menu.
Pass { preventScroll: true } so focus still moves to the acted-on task for keyboard continuity, but the viewport stays anchored where the user acted.
* docs(android): document SDK 28 (API 28) behind-keyboard add-task bar root cause
After #8508 (patch removed in 18.12.0) a user on Android 9 / API 28 reports the
global add-task bar sitting behind the soft keyboard — the device class open
item #4 predicted.
Capture the precise root cause (edge-to-edge forces no system resize; Type.ime()
unreliable < API 30; old WebView VisualViewport doesn't shrink, so
--keyboard-height stays 0) and why a web-side height fallback is the reverted
#8295 trap (obscured ≈ 0 in both the resized and non-resized cases). Record the
correct resize-detecting native fix recipe and the device-matrix gate it needs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs
* fix(android): lift add-task bar above keyboard on API < 30 (#8508 follow-up)
On Android 9 / API 28 under enforced edge-to-edge (targetSdk 36) the system
does not resize the window for the IME and WindowInsetsCompat.Type.ime() is
unreliable, so the edge-to-edge plugin never insets the WebView and neither the
window nor VisualViewport shrinks. --keyboard-height stays 0 and the
position:fixed add-task bar sits behind the keyboard.
Add a resize-detecting native inset in CapacitorMainActivity, driven from the
existing keyboard OnGlobalLayoutListener: measure the keyboard via
getWindowVisibleDisplayFrame (reliable on every API level) and, only when the
WebView actually extends behind the keyboard, lift it by the exact overlap via
bottomMargin (matching the plugin), self-correcting as the keyboard height
changes and restoring the captured margin on hide. Gated Build.VERSION.SDK_INT
< 30 so it is a strict no-op on every device #8508/18.12.0 verified.
Web-side height fallback was rejected: obscured = innerHeight - vvHeight is ~0
in both the resized and non-resized cases, so disambiguating requires the
baseline-innerHeight + max(obscured, nativeKb - layoutShrink) math that was
reverted as #8295. Native has the unambiguous geometry.
Needs on-device validation across the matrix in docs/android-edge-to-edge-keyboard.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs
* fix(android): harden SDK < 30 keyboard inset after review
Address findings from a multi-agent review of adjustWebViewForKeyboardBelowApi30:
- Bound the feedback loop: clamp appliedKeyboardInsetPx to [0, keypadHeight]
(the lift never needs to exceed the keyboard height) and skip redundant
layoutParams writes, so a WebView height that doesn't respond to the margin
(e.g. the edge-to-edge plugin rewriting it) can't drive an endless
requestLayout loop.
- Ignore stale/pre-layout geometry while the keyboard is open (webView.height
== 0) so an already-applied inset isn't collapsed to 0 for a frame; only the
genuine keyboard-closed path restores the margin.
- Make the dead-band density-relative (8dp) instead of a fixed 16px so it's
visually constant across densities.
The reviews confirmed the central risk is absent: shrinking the WebView via
bottomMargin shrinks both innerHeight and visualViewport.height, so the web
side's obscured stays under its 100px floor and does not add a second lift
(no #8295 double-count). Still pending on-device validation per the docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs
* fix(android): resize WebView via height for SDK<30 keyboard inset
On API < 30 under enforced edge-to-edge the system does not resize the
window for the IME, so the add-task bar sat behind the keyboard (#8508
follow-up, Android 9 / API 28).
The edge-to-edge plugin owns webView.bottomMargin and rewrites it to 0 on
every inset dispatch while the IME is visible, so correcting the margin
from a second writer flickers; WebView padding does not move the web
layout viewport; and fully replacing the plugin's inset listener stops it
re-painting its color overlays (white navbar gap).
Instead set an explicit WebView layout height to the keyboard top while
the IME is up and restore the resting height on hide. Height is a
different property than the margin the plugin manages and, for an
explicit-height view, the margin does not change the view size — so the
two never fight and the plugin keeps doing everything else (insets +
color overlays). Gated SDK_INT < 30, so API >= 30 is untouched.
Also documents the root cause, the approaches that failed, and the
upstream status, plus a handover/plan for migrating to Capacitor's
built-in SystemBars.
* fix(android): guard zero-height + reuse loc buffer in keyboard inset
Multi-review follow-ups for the SDK<30 keyboard WebView-height workaround:
- Skip the write when the computed keyboard-top height is <= 0 instead of
coercing to 0. A 0 would collapse the WebView, and the existing
height==0 guard would then latch and stop recomputing until the keyboard
hides.
- Reuse a single IntArray for getLocationOnScreen instead of allocating
one on every layout pass while the IME is up (hot path).
- Document the implicit coupling with StartupOverlayManager: both read/
mutate webView.height from layout listeners and only coexist because the
overlay early-returns once its input bar is visible. Cross-referenced in
both files so the guard isn't removed later.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* test(plainspace): cover Plainspace→SP done-state poll sync
Lock in that completing/reopening a task in Plainspace propagates isDone
through both poll paths (getFreshDataForIssueTask and the bulk
getFreshDataForIssueTasks) so SP marks the linked task done. This was the
one untested link in the inbound completion-sync chain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ftsr6w7A2KdVgkpVetXHcc
* fix(sync): don't let an outbound push mask a concurrent remote completion
When a task is completed in Plainspace (or any two-way-sync provider) and the
user then pushes an unrelated mapped field from SP (a rename or reschedule)
before the next poll, _pushChanges$ advanced issueLastUpdated to the remote's
latest updatedAt — which already reflected the completion. The poll's
"updatedAt unchanged" guard then treated the completion as already-seen and
never pulled isDone, silently dropping it.
Detect un-pulled remote changes in mapped fields we did NOT push this cycle
(freshIssue is fetched before the write, so it still carries them) and, when
present, keep issueLastUpdated stale so the next poll reconciles them — mirroring
the existing hasProviderOwnedSkip guard, widened from changed fields to all
mapped fields. Lives in the shared effect, so it also hardens CalDAV
(strictly more conservative: it can only ever prevent dropping remote changes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ftsr6w7A2KdVgkpVetXHcc
---------
Co-authored-by: Claude <noreply@anthropic.com>
A lock-acquisition timeout is self-healing (the next cycle retries once
the holder frees) and since #8306 it no longer wedges the write queue.
Gate the LOCK_TIMEOUT_ERROR snack on isUserTriggered, matching the
adjacent NETWORK_ERROR/TIMEOUT_ERROR branches, so automatic
resume/focus/interval syncs no longer flash a 'try again' message nobody
is waiting on. The diagnostic SyncLog.err line stays unconditional.
The v18.11.0 patch (5497212b9) always inset the WebView by the IME height,
assuming Android 15/16 enforces edge-to-edge so the system no longer resizes for
the keyboard. On real devices that is false: an Android 16 phone still resizes
the window for the IME (window.innerHeight 732 -> 141 with the keyboard up). The
patch then added another ~909px inset on top -> the WebView was squashed to a
~141px sliver with a large blank gap above the keyboard (the "can't see what I'm
writing" symptom).
Removing the patch restores the plugin's stock behavior (no inset while the
keyboard is visible), letting the system resize handle it. Verified on an
Android 16 phone: gap gone, WebView fills the resized window, bar sits just above
the keyboard (no behind-keyboard regression).
Doc rewritten with the root cause, the ruled-out theories (ngModel writeValue,
DOM churn, SDK-gate/latch), open items (reversal still to confirm on reporters'
devices; system resize on suggestion-strip oscillation; a resize-detecting inset
as the long-term fix) and an updated device test matrix.
The v18.11.0 patch always inset the WebView for the IME, which shipped two bugs:
1. double-count on API <=35 (the system still resizes for the IME there - pre-
enforcement, and we opt out on API 35 via values-v35) -> a keyboard-height
blank gap above the keyboard;
2. the inset is reapplied on every OnApplyWindowInsetsListener callback via an
unconditional setLayoutParams(); the IME inset fluctuates during typing
(suggestion strip, layout switches), so the WebView relayouts mid-composition
-> the IME composing region resets -> reversed/invisible characters.
Corrected patch (regenerated via patch-package):
- gate the inset to API 36+ where edge-to-edge is actually enforced (no system
resize) -> no double-count, no gap on API <=35;
- latch the keyboard inset while the keyboard stays visible -> no relayout on
inset fluctuations during composition;
- skip setLayoutParams when no margin changed.
Verified: white gap gone on API 34 emulator. API 36+ typing-reversal half still
needs real-device confirmation (not reproducible headless / on old emulators).
Doc updated; do-not-naively-reapply guidance retained.
The adjustResize IME animation could briefly expose the layer behind the
WebView as a white flash. The WebView surface was already painted with the
theme color, but the window decor behind it was not: windowBackground was
@null, falling back to the static Light AppTheme default (~white), and
nothing repainted the decor at runtime.
- styles.xml: wire AppTheme.NoActionBarLaunch windowBackground to the
existing @color/windowBackground (has a values-night #131314 variant), so
the decor tracks system day/night instead of defaulting to white.
- NavigationBarPlugin: also setBackgroundDrawable(ColorDrawable) on the
window alongside the existing webView.setBackgroundColor, so the decor
stays matched to the in-app theme when it diverges from the system setting.
* fix(plainspace): don't tint claim-list link icon with accent color
* fix(sync): clarify Nextcloud connection-test 404 (wrong user ID) (#7617)
A base-root 404 in the WebDAV connection test means auth succeeded but the
DAV path /remote.php/dav/files/<userName>/ does not exist — i.e. the
"Username" holds an email/display name instead of the account's user ID
(Nextcloud accepts email/login for auth but the files path needs the uid).
Previously this surfaced as a bare scrubbed hostname in the snack, which
users misread as a stripped URL. Now:
- WebdavApi.testConnection maps thrown errors to a readable, privacy-safe
message plus an HTTP errorCode discriminator (404/401/status).
- The Nextcloud "Test Connection" path shows a specific hint on 404
explaining that "Username" must be the user ID, not email/display name.
- Wiki + field guidance clarified accordingly.
The overlay-opacity and blur sliders hide when no background image is
set. Formly's default resetFieldOnHide wiped their model value to
undefined while hidden, so removing and re-adding an image reset the
opacity slider to 0% instead of the configured value (default 20%).
Mark both sliders resetOnHide:false so the value survives the hide/show
cycle.
Closes#8504
* fix(op-log): lock snapshot save to prevent lost-update window
saveCurrentStateAsSnapshot() read NgRx state then lastSeq without
holding OPERATION_LOG lock. An op appended between the two reads would
get seq <= lastAppliedOpSeq but its effect would be absent from the
snapshot. On next hydration the tail replay would start after that seq,
silently skipping the op forever.
Fix: wrap in lockService.request(LOCK_NAMES.OPERATION_LOG, ...) and
read lastSeq BEFORE state snapshot so the worst interleaving degrades
to harmless re-replay (idempotent) rather than a missed op.
Fixes#8308
* fix(op-log): address review feedback on snapshot lock PR
- Amend JSDoc idempotency claim: syncTimeSpent is additive on re-replay
- Add inline note about compaction's opposite read order and worse failure mode
- Add lock regression tests (#8308): lock acquired, read order, error handling
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(sync): remove unused error classes, dialog, and constructor-time logging
Phase 1 of #8325: clean up orphaned sync error types and their UI.
Removed error classes that are no longer thrown anywhere:
- NoEtagAPIError, FileExistsAPIError (unused API errors)
- RevMismatchForModelError, SyncInvalidTimeValuesError (superseded by file-based flow)
- RevMapModelMismatchErrorOnDownload/Upload, NoRemoteModelFile, NoRemoteMetaFile
- LockPresentError, LockFromLocalClientPresentError, MetaNotReadyError, InvalidRevMapError
Removed DialogSyncErrorComponent and all references in SyncWrapperService
(_forceDownload, _handleIncoherentTimestampsDialog, _handleIncompleteSyncDialog,
_openSyncErrorDialog, _extractModelIdFromError).
Removed constructor-time logging from JsonParseError, ModelValidationError,
DataValidationFailedError — these errors are logged at the catch site; redundant
construction-time logs risk leaking user data.
Cleaned up dead translation keys (D_INCOMPLETE_SYNC block, DIALOG_RESULT_ERROR,
ERROR_DATA_IS_CURRENTLY_WRITTEN) from en.json and t.const.ts.
Updated file-based-sync-flowchart.md to reflect the removed error types.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(plainspace): don't tint claim-list link icon with accent color
* feat(plugins): migrate Azure DevOps issue provider to a plugin
Move the built-in Azure DevOps provider out of core into a bundled plugin
(packages/plugin-dev/azure-devops-issue-provider), mirroring the prior
GitHub/ClickUp/Gitea/Linear/Trello migrations.
- Read-only provider: WIQL search + backlog import (scope all / created-by-me
/ assigned-to-me), two-step work-item fetch, getById with description.
- PAT auth via HTTP Basic (empty username); allowPrivateNetwork enabled so
self-hosted Azure DevOps Server hosts keep working.
- getIssueLink builds the work-item URL from host + project + id without a
request; isDone maps (pullOnly) from Closed/Done/Removed/Resolved states.
- issue-provider.reducer migrates legacy AZURE_DEVOPS providers to plugin
shape on load (project listed first so the tooltip/initials keep showing
the project); auto-enable already covers migrated keys generically.
- AZURE_DEVOPS moves from BuiltInIssueProviderKey to MigratedIssueProviderKey;
the synced issueProviderKey union member is preserved for forward-compat.
- i18n: ported the translated F.AZURE_DEVOPS.FORM.* config labels (de/en/uk
localized, English fallback elsewhere) and shared issue-content display
labels to all 28 app locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(plugins): tidy Azure DevOps plugin issue field mapping
Post-review cleanup of the field mappers (no behavior change for the
common case, and closer to the Linear/Gitea reference plugins):
- mapReduced no longer emits a redundant `state` field; the typed `status`
already drives the search-list done indicator (is-issue-done), and dropping
`state` keeps isDone unset on add-from-search — matching the built-in, which
never set isDone in getAddTaskData.
- mapIssue keeps `state` as the single canonical status field (used by
issueDisplay, the isDone fieldMapping and extractSyncValues) and no longer
duplicates it as `status`; the display row now points at `state`.
- Compute the work-item summary once per mapper instead of twice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(plugins): remove dead Azure DevOps form i18n keys after plugin migration
The built-in Azure DevOps config form was deleted in the plugin migration,
so the F.AZURE_DEVOPS.FORM.* keys in t.const.ts and the app locale files
(en/de/uk) are now unreferenced. The plugin ships its own bundled i18n.
Removing them matches the GitHub/Trello/Linear/ClickUp migration cleanup.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Retire GET /api/sync/snapshot (attack-surface reduction):
- Remove route handler from sync.routes.ts (no production client caller)
- Keep internal generateSnapshot() and all downstream code
- Update tests: delete GET /snapshot describe block, rework isolation
test (sync.routes.spec.ts), remove GET assertion (sync-fixes.spec.ts),
remove SimulatedClient.getSnapshot() helper + rework 2 integration tests
to use GET /api/sync/ops instead (multi-client-sync.integration.spec.ts)
Re-scope GET /api/sync/status as diagnostic:
- Add doc comment to route handler marking it diagnostic
- Update all documentation (README, API wiki, architecture diagrams)
Documentation updates across 5 files remove GET /snapshot references
and label GET /status as diagnostic.
Breaking: self-hosters with external tooling relying on GET /snapshot
must migrate — no shipped client version ever called this endpoint.
The accent-colored "issue updated" indicator could previously only be
cleared via the "mark as checked" button inside the issue-content panel,
which renders only once the issue data (re)loads. For Plainspace tasks —
and any provider whose remote issue is gone/unreachable — the data never
loads, so the badge had no way to be dismissed and stayed stuck forever.
Clicking the task's detail-panel toggle button while it shows the accent
update icon (i.e. issueWasUpdated) now also marks the issue update as read.
This is always available and does not depend on the issue data loading, so
the badge can never get stuck. The manual "mark as checked" button stays.
Claude-Session: https://claude.ai/code/session_01B9SCXSQvTq8TKUL7LabJQS
Co-authored-by: Claude <noreply@anthropic.com>