mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
dbbbefda75d4fc4aaeac7bdaecd65eab772f00a9
302 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5035168e5c |
fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1) r1 rulings (lead, on BUG-3982): - F1: docs/api.md says details.token_workspace is absent for a deleted pin. - F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one statement); any other id is the missing-id 404. - Q1: an MCP tool mapped to a global route (pad_workspace create) is refused for a pinned PAT, tested through wireMCP. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test(e2e): the suite token is unpinned (BUG-3982 CI) global-setup minted the browser suite's token with a workspace_id on a stale premise (api_tokens once required one). Under BUG-3982 a pinned token is refused the global routes the UI calls on every page, so the E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and /auth/export. task-3415's deliberate pin is kept. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3) GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter, but the token route gate read "import-status" as a slug and admitted a pinned token, which could then read the user's import outcome for any workspace by key. It joins reorder, import and deleted as a refused segment. The route walk skipped every route the gate admits, so it could not catch a route admitted wrongly. Each admitted route must now sit inside the workspace subrouter or be in a hand-written expected list, and the list must match what the gate admits. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4) r4 rulings (lead, on BUG-3982): - On every door that takes a workspace by name (RequireWorkspaceAccess, SSE /events?workspace=, search ?workspace=), a pinned token naming any other workspace is refused before the name is resolved (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug get byte-identical refusals. The resolved-ID compares stay behind it. - The email-restricted share link and the invitation preview read the caller through signedInFor: a token pinned to another workspace counts as signed out there. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4) The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant rerun showed that mutant surviving. This direct test, shaped like TestBUG3982_WorkspaceAccessComparesThePinByID, holds it. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5) r5 rulings (lead, on BUG-3982): - authorizeCrossWorkspace refuses a pinned token naming any other target_workspace before resolving it, so copy and preflight give a missing, non-member and member target the same refusal. The OAuth allow-list check, reported after the role check, is unchanged. - The /-/r/ resolver checks the pin by name before resolving; its answer was already the same 404. - Both join the identical-bytes test; the resolver also gets a by-ID test, since its resolved-ID compare is now behind the by-name one. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6) r6 rulings (lead, on BUG-3982): - authorizeCrossWorkspace compares the pin by resolved ID behind its by-name check, like RequireWorkspaceAccess, SSE, the resolver and collab, with a by-ID test. - Collab's resolved-ID compare gets the by-ID test the others have. - docs/api.md: five BUG-3982 sentences appended without a separator now end the sentence before them. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 |
||
|
|
a0c7d68aa9 |
build(web): SvelteKit 3 + adapter-static 4 (TASK-3423 PR B) (#1835)
* wip(web): SvelteKit 3.0.1 + adapter-static 4 with the $lib alias (TASK-3423 PR B, WIP) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * wip(web): kit 3 migration fixes - goto reset, page.url.href, $app/env, $app/state, cookie v2 (TASK-3423 PR B) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): pane Back restores scroll through kit 3's snapshot() (TASK-3423 PR B) PaneHost read page.state one task after popstate; kit 3 applies a popped entry after an await, so the saved paneScrollTop was never seen. Kit's own snapshot() keeps the position per history entry, captures it when the entry is left, drops it on a replace, and restores only on a traversal after the entry is applied. The controller's replaceState(paneScrollTop) write, the getPaneScrollTop dep and readPaneScrollTop are gone. The 9 bug-2182 legs pass. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): the back-settle latch opens at the Back's popstate, where kit 3 arms it (TASK-3423 PR B) Kit 3's popstate handler is async, so afterNavigate (where the settle is armed) now runs after every popstate listener; measured 2-30ms after the helper's listener. Not yet run green: the box is held. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): the pane back-settle waits for an in-flight drill instead of minting the popped-to item (TASK-3423 PR B) Kit 3 resolves a goto to afterNavigate across tasks, so a drill fired inside the 140ms window could still be in flight when the settle fired; it minted the popped-to item first (measured: URL still ?item=A at fire time, 2 of 4 runs). The settle now re-arms while navigating.current is set. Unit tests cover the defer and the cancelled-navigation fallback; both die without the defer. e2e not yet re-run on this commit. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): defer the back-settle on navigating.type, not a nonexistent navigating.current (TASK-3423 PR B) 79175a8bc read navigating.current, which kit 3's $app/state navigating does not have: undefined !== null held always, so the settle deferred forever. The R14 drill legs passed for that wrong reason and 432 (a drill AFTER the window still sees A minted) failed 5/5. navigating.type is null exactly when no navigation is in flight. Measured at this commit: pane-async-race 373 and 432 and capstone 645, 5 repeats each, 15/15 pass. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): the list's restore-skip is released by the next navigation, not a microtask (TASK-3423 PR B) Closing the pane from a scrolled column jumped the list (bug-3165 e2e): the handoff wrote the anchored position at 52ms, then the scroll restore applied the list entry's saved offset (top 600) from 82ms on (measured with a scroll-write probe after Escape). Kit 3 calls snapshot.restore after an await in its navigate, so the queueMicrotask release ran before the restore it was meant to skip. The skip now belongs to the navigation that armed it: its own restore consumes it and the next beforeNavigate releases any remainder. bug-3165 spec: 6/6 desktop legs pass. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): editor menus unsubscribe from the editor they subscribed to, not the live prop (TASK-3423 PR B) version-restore-collab (BUG-2264's e2e) blanked the item page after a restore under live collab, 3/3 on kit 3: the force-refresh remount ran EditorBubbleMenu's and EditorLinkPopover's effect cleanups, which read the live editor prop. That read re-entered Svelte's flush and destroyed the editor mid-transaction (state_unsafe_mutation from ProseMirror's blur handler), and a null prop then threw on .off, killing the page (stacks captured with Error.stackTraceLimit=100). The cleanups now close over the editor their run subscribed to. Evidence is the e2e: red 3/3 at 347942e17, green 3/3 (2 tests x 3) with this change alone. A jsdom unit test could not reproduce it: there Svelte hands a teardown the pre-teardown prop value, so the old cleanup passed too, and a test that passes either way was not kept. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): kit 3 in code main added since PR B: $app/env, goto reset in the library tabs (TASK-3423) * test(web): re-review the identity-gate rows the rebase onto main re-hashed (TASK-3423) collection page: quickCreateInColumn, the two pane-follow continuations, installPaneTestHook, loadUrlFilters and updateUrlFilters. Only kit 3 API swaps reach them (page.url.href, goto reset:false); no await or fence moved. library page: the tab-switch goto now restores focus to the tab after the navigation (kit 3 has no keepFocus); its continuation writes no state and is tabled. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): quick capture's Open lands in the title editor under kit 3 (TASK-3423) The captured item's URL carries ?new=1, which opens it with its title in the editor. Under kit 2 the follow-up goto's focus reset closed the editor at once, so the title read as text; kit 3's reset:false keeps it open (site 3 of the day-87 ruling). Assert the editor's value instead. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF * test(e2e): login honours ?redirect= and still refuses off-site targets (TASK-3423) The login page now reads ?redirect= through $app/state's page, and that value is validateRedirect's input. Nothing exercised the read: the share and invite specs check only the link that points at /login. Covers the form path from a signed-out context, the signed-in forward, and three off-site shapes (absolute, protocol-relative, encoded slash). Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF * test(e2e): name the encoded-slash case by what the guard receives; off-site on the form path too (TASK-3423 r2) Codex r2: the encoded-slash case is a value that still holds %2f after searchParams decodes it once; the label and a comment now say so. Hunter r2 L1: the form sign-in also gets an off-site target. Hunter r2 L2: the off-site cases wait on one poll for "this origin, past /login", so an intermediate URL cannot satisfy half of it. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF * fix(web): the activity page's URL clear uses kit 3's goto options (TASK-3423) #2226 landed dropFromUrl with goto's noScroll/keepFocus, which kit 3 removed (they throw in dev and are ignored in prod), and new URL(page.url) on the $app/state proxy. reset: false keeps scroll and focus as both options did; page.url.href gives URL a string. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF |
||
|
|
ed559deb4d |
feat(web): catch-up card, Mark caught up, and See everything into Activity (TASK-3615, PR B) (#2226)
* wip(web): catch-up card on the workspace home, Mark caught up, and See everything into Activity (TASK-3615, PR B) The card renders GET /catch-up above Active Work, fenced on identity and keyed on (user, workspace). Mark caught up PUTs /me/seen and refetches. See everything opens Activity in Audit with ?since= set to the window's start, so the activity endpoint's since now also accepts an RFC3339 instant (shape item 5, moved from PR A to PR B by the lead's ruling). Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * test(e2e): catch-up card flow against the real binary (TASK-3615, PR B) Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * test(e2e): register the catch-up teammate with the admin session, not its token (TASK-3615, PR B) Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * fix(web): catch-up r1 fixes: since validation matches Go, loads are ordered, a failed refetch keeps the card, a mounted Activity page applies a linked view (TASK-3615, PR B) - sinceFromUrl range-checks the fields Go's time.Parse checks instead of trusting Date.parse, which rolls 2026-02-30 over and accepts T24:00:00 (and refused offsets Go takes, up to +-24:60). The table's verdicts are pasted from go1.26. - CatchUpCard numbers its loads, so A->B->A cannot let the first A answer paint over the second. - A mark that lands but whose refetch fails keeps the card and says the mark worked, instead of clearing it. - The Activity page applies a changed ?view= on a mounted page, as it already did for ?since=, still without persisting it. - The e2e waits for a new second before Mark, since the window floor is inclusive at second precision. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): clearing the since chip or Clear filters drops since from the URL (TASK-3615, PR B) Clearing only filterSince left ?since= in the URL, so a reload re-applied a cleared filter, and re-following the same link changed no URL value, so the URL effect never re-ran. Both clear paths now navigate (replacing history) to the URL without since. The shallow replaceState would leave page.url, and so the derived since, stale. The route has no load function, so the navigation re-fetches nothing. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): a clear whose navigation rejects still drops since from the address bar; the e2e reload leg asserts on the request (TASK-3615, PR B) r3 findings. clearSince falls back to the shallow replaceState when its goto rejects, so a reload stays cleared. A superseded goto needs nothing, since the newer navigation decides the URL. The e2e reload leg now checks the reloaded feed request carries no since, instead of a chip absence that passes before a late effect could add one. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): choosing Live or Audit drops view from the URL, so a reload keeps the choice (TASK-3615, PR B) r4 Medium, the same class as the since fix: a Live or Audit click cleared viewFromUrl but left ?view= in the URL, so a reload or a revisit re-applied the linked view over the user's choice. The page reads two URL values, since and view, and every path that clears or overrides either now drops it from the URL through one helper. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(server): refuse an activity since whose UTC instant passes year 9999 (TASK-3615, PR B) r5 finding. An offset can carry 9999-12-31 into year 10000, and the store compares the cutoff as RFC3339 text, so "10000-..." sorted below every row and opened the window to everything. The handler now refuses it with 400 invalid_since, and sinceFromUrl mirrors the bound so such a link loads the unfiltered feed instead of an error. The new rows' verdicts come from a Go probe of the handler's condition. docs/api.md says so. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * test(web): census entry for CatchUpCard's keyed sections each (TASK-3615, PR B) eachKeyCensus.test.ts requires every keyed each to name why its keys cannot repeat. CatchUpCard's sections come from a four-entry literal with distinct ids, only filtered. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW |
||
|
|
b901ec629c |
feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)
Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.
Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)
Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)
Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.
describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)
Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.
The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.
Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
|
||
|
|
c086457939 |
fix(web): a shown kept draft is held, so newer kept text cannot replace it (BUG-3863) (#2215)
* fix(web): a kept draft the notice shows is held, so newer kept text cannot replace it (BUG-3863) The crash keeper (and every other producer) writes one slot per user and item, so text kept while the notice showed an earlier draft D replaced D's only stored copy, and a reload before Restore lost it. A shown draft from before this tab's keeper now moves to a second, held slot that producers never write or clear; Restore, Discard and the clear arm resolve it, and the pending text is offered after it. The live keeper's own in-session draft stays pending, where its stale rewrite and confirm-clear reach it. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(web): a crash keeper reaches a held copy of its own draft by its id (BUG-3863) Another tab can hold a draft this tab's keeper is still tracking. Each keeper now stamps its drafts with its own id; a send flags a held copy carrying that id stale whatever its text, and a confirmation removes it only when its text is exactly the confirmed body. A held draft with no id or another keeper's id is never touched (lead two-tab ruling). Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(e2e): the held-draft restore answers the pending-edits dialog; the stale rewrite reaches the held copy by id (BUG-3863) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(e2e): after D is restored the held-draft spec expects E offered at once (BUG-3863) Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ |
||
|
|
c0faa52b2e |
fix(web): a kept draft with a stale base says it can't be applied and offers only Copy (TASK-3856) (#2213)
* fix(web): a kept draft with a stale base says it can't be applied and offers only Copy (TASK-3856) A crash draft flagged baseStale can never patch: Restore went straight to the conflict notice. The notice still promised "restoring applies only your changes", so most online crash drafts offered a one-click restore that ended in a refusal. It now says up front that the changes can't be applied on top of the newer version, leads with Copy, and offers no Restore. No diff is shown, because the base is the part that is not known. Non-stale drafts and whole-body restores are unchanged. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(web): the stale-draft notice says why the changes can't be applied, and shows no diff (TASK-3856 r1) "The version you started from isn't known" was false when a remote update or this tab's own send outran a confirmed snapshot: the start is known, but the server may already hold text the draft counts as its own. The notice now says the changes can't be told apart from the newer ones. The component and e2e tests also assert that no diff is shown in that state. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(web): Restore reads the stored draft first, and a stale draft's notice says some of it may be on the server (TASK-3856 r2) The crash keeper rewrites a draft it kept as stale when this tab sends, and that write does not refresh the copy the notice shows. Restore patched from a base the keeper had already marked untrusted, which can send text the server already holds a second time. restoreRefusedDraft now re-reads the stored draft; when it has the same text and is now stale, the notice re-renders without Restore and nothing is written. In the stale state the notice no longer says the edits did not reach the server: some may have, and a user re-applying by hand would duplicate them. That state now says they "may not all have reached the server"; every other state keeps the shared line. The ItemDetail AST review row for restoreRefusedDraft is re-reviewed with its new hash. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(web): a stale rewrite of the shown draft stops only a patch restore (TASK-3856 r3) The r2 check switched the notice to its stale state for any draft the keeper rewrote as stale, including one restored whole because the item had not changed. There the notice cannot change, so the first Restore click did nothing. The check now also requires a patch restore; a whole-body restore never reads the base and goes ahead. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(web): the whole-body stale-rewrite e2e pins the request the first click sends (TASK-3856 r3) With the editor open, the server can refuse a whole-body restore over pending edits and the existing flow asks first, so the row is not the right observable. The test now asserts that the first click sends the whole kept text, which the r3 guard regression would never send. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ |
||
|
|
64398c95c8 |
fix(web): rich crash drafts anchor on the editor's confirmed snapshot; patch restore waits for the open editor (BUG-3850) (#2207)
* wip(web): rich crash drafts anchor on the editor's confirmed snapshot (BUG-3850) * test(web): BUG-3850 confirmed base, restore planner and keeper pins * test(web): BUG-3850 e2e for a body stored with a trailing newline; AST row re-reviewed * fix(web): the confirmed base is taken on the barrier ack, and its row is read with it (BUG-3850 r1) * test(web): onConfirmed stays silent when an edit made while closing never went out (BUG-3850 r1) * test(web): BUG-3850 ItemDetail binds the confirmed base (source-level wiring) * fix(web): the confirmed base is refreshed before the first local change after a remote one (BUG-3850, lead ruling c9db9612) * fix(web): a crash draft too large with its raw row is kept without it before other drafts are evicted (BUG-3850 r2) * fix(web): restore retries on the raw row only when it is the same document as the base (BUG-3850 r2) * test(web): collabFlusher.storageForm (BUG-3850) * fix(web): a rich patch restore waits for the open editor; the raw-row retry is removed (BUG-3850, lead ruling 0337bf77) A rich crash draft's anchors are in the editor's form. The server applies a restore patch to the document in that form only through an open editor; with none it matches the stored row, where a raw-only form (a trailing newline, underscore emphasis) makes them miss. The guarded raw-row retry could not run there either: with no room the editor is not mounted, so the same-document check had no canonicalizer and never passed (measured by e2e). So the patch restore waits until this tab's editor is mounted and its CURRENT connection is synced (provider.state, not `synced`, which persists across a reconnect). While it waits, Restore is disabled with the reason and Copy leads. The retry, rawBaseBody, storageForm and the raw-row eviction step are removed (reverts a612c0680, ab711ec35, bb26af25f); applyRefusedDraftChanges is main's again. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(web): a crash draft whose base predates a remote update restores through the conflict notice (BUG-3850, lead ruling baa63dc3) The confirmed base is taken only while nothing local is unconfirmed, so a collaborator's text that lands during a typing streak (or after a disconnect that left edits at risk) cannot be in it. A patch from that base sends the text again and reports success: 'A' -> 'A C U' over a stored 'A C' restores as 'A C U C'. The provider now counts remote updates alone (remoteDocVersion, bumped where handleDocUpdate already branches on origin === this). The tracker records it with the held snapshot and reports remoteAfter once it moves; the crash draft stores baseStale in the same write, and restoreEdits returns no edits for it, so restore takes the existing conflict branch: Copy, nothing written. A later confirmed refresh re-takes the base with the remote text and clears the flag. Also destroys the Tiptap editors confirmedBaseRemote's tests create: a live view's DOMObserver timer firing after jsdom teardown was an unhandled error that failed the full run. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(web): a kept draft is rewritten when its base moves, and a rich draft with no confirmed base is flagged stale (BUG-3850 r5) The crash keeper skipped a write whenever the markdown matched its last one, so a base that had moved or turned stale with the same text was never stored (r5 Codex Low: a remote ' C' deleted again within one tick leaves the draft unflagged over a server that holds 'A C'). The skip now compares the base seq, body and stale flag too. With no confirmed snapshot, a rich draft fell back to the row as an unflagged base. The row is read before the provider's sync, so the tab cannot say the server holds nothing it lacks; under lead ruling baa63dc3 that draft now restores through the conflict notice (r5 hunter-L1). Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(web): a crash draft whose base this tab's own send outran restores through the conflict notice (BUG-3850, lead ruling a6380212) A rich draft's base is the last confirmed snapshot, but the draft also counts edits this tab sent that the server may already hold: a crash before the barrier ack, or a typing streak the ack never catches up with. At the end of the document an anchor is a substring, so restore sent that text again and reported success ('A' -> 'A U1 U2' over a stored 'A U1' gave 'A U1 U2 U1'; an applier patch gave 'A X U X'). The provider exposes localSentVersion (its local send count); the tracker records it with the snapshot and current() reports sentAfter when it has moved, and the kept draft is flagged stale on remoteAfter or sentAfter. A confirmed refresh on an unchanged document takes the count without serializing, so a reconnect's catch-up resend does not flag the next offline draft. Edits never sent still patch. A stale draft no longer waits for the editor: it can only reach the conflict notice (r7 hunter-L1, permitted by the ruling). Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(web): a kept crash draft is flagged stale before a local send leaves, not at the next tick (BUG-3850 r8) Offline typing keeps an unflagged draft, which is right: nothing was sent. A reconnect's catch-up then sends it, nothing is at risk any more, and the keeper's next tick clears the draft. A tab that dies between the send and that tick left an unflagged draft while the server may hold the text, and restore duplicated it at the end of the document ('A' -> 'A U' over a stored 'A U' gives 'A U U'; r8 hunter-1). The provider calls onLocalSend just before any local update goes out (markLocalSent: a live edit, the pre-anchor flush, a catch-up). Through it the keeper rewrites the draft it holds for the shown item flagged stale, once, before the frame is queued. A tick-side key could not close this: no tick runs in the window. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * fix(web): the pre-send rewrite never overwrites a newer draft another producer kept (BUG-3850 r9) The keeper's onSent rewrite wrote its last markdown unconditionally. If keepAtRiskText (session expiry, an identity change) had kept the editor's newer text since the keeper's last tick, a send replaced it with the older copy, and a tab that died before the next tick lost the difference (r9 hunter-2). The rewrite now runs only while the stored draft is still the one this keeper wrote, the guard clearRefusedRawDraft already uses; otherwise the keeper gives up on it, so later sends do not read the store on every keystroke. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ * test(web): re-review the three ItemDetail AST rows the r7/r8 changes moved (BUG-3850) restoreRefusedDraft's body is unchanged; restoreBlocked, which it reads, gained a synchronous !refusedDraft.baseStale read. The two force-refresh continuations are unchanged; their enclosing collab effect gained onLocalSend (synchronous, no await, no generation or identity write). Each row records the re-review and its new hash. Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ |
||
|
|
6ca32c2b14 |
fix(web): the playbook editor offers a built-in's library update (BUG-3852) (#2209)
* fix(web): the playbook editor offers a built-in's library update (BUG-3852) The Library's "Update available" card for a built-in playbook opens the dedicated playbook editor, which had no review dialog, so the update could be neither reviewed nor accepted from the web UI. The editor now mounts BuiltinUpdateNotice, the same badge and dialog the item pane has. Accept refuses while the form is dirty or saving, and an accepted update reloads the form, so a later Save cannot send the old body back. An e2e test stages a real upgraded origin (the item holds its seed text, and that text is not the library's), then goes Library card → editor → review → accept. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * test(e2e): BUG-3852 asserts the update_available dialog's own text Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(web): the playbook editor locks its form while a built-in update is accepted (BUG-3852) Review r1 (Codex M1 and hunter L1): the dialog can be closed while Accept's request is in flight, which hands the page back, so an edit or a Save could race the update and onAccepted then skipped the reload silently. - BuiltinUpdateNotice gains an optional onBusyChange callback. The editor locks the title, the settings, the body and both Save buttons while an accept is in flight. If the form is dirty anyway, a backstop toast says the update landed. - The notice's props are null-safe. The accept's reload clears `item` while the notice's request is still settling, and its finally read `item.id` and threw. The new unit test caught it. - The identity gate rows for loadItem and save are re-reviewed (accepting is cleared synchronously at entry, and the entry return is `locked`). Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi |
||
|
|
5ea7fde86b |
fix(web): keys typed into an editor frozen by force_refresh recovery stay in the editor (BUG-3786) (#2138)
* fix(web): keys typed into an editor frozen by force_refresh recovery stay in the editor (BUG-3786) BUG-3697 made the editor read-only through a force_refresh recovery. A read-only Tiptap editor is contenteditable=false with no tabindex, so the browser drops its focus to <body>, outside the item pane. The page and layout shortcut guards then no longer see a text field: the BUG-3697 spec's "Typed during recovery." opened the quick-add dialog on its `c`, and the rest of the text went into that dialog. For a user, an arrow or Enter would also move the pane to another item. While frozen, the editor keeps a tabindex of -1, so it stays focusable inside the pane and behind the pane's guards, and a typed character stops propagating at it: exactly what characterKey() returns, which is what every single-key shortcut reads. Escape, Tab, arrows, Enter, space and chords still reach their window owners (DockedSheet, the PaneHost focus trap, scroll restore), as they do from an editable editor; codex r2 and r3 found those owners when the hold was wider. ProseMirror's listener is on the same node and still sees every key. The spec is live again (the test.fixme that #2137 parked it under is removed), and now asserts that the frozen editor holds focus and that no quick-add dialog opened. Counterfactuals: without the tabindex the focus premise fails; with the tabindex but without the keydown hold, the quick-add assertion fails. Main's CI failure (the pane ending on another item) did not reproduce locally, alone or with the shard's neighbouring specs. The quick-add leak reproduced on every local run, including passing ones. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(e2e): the BUG-3697 spec focuses the frozen editor instead of force-clicking it (BUG-3786) #2138's first CI run went red on E2E desktop 1/2 at the focus premise, before any typing. The trace (run 38472264516) shows the pane closing during `click({ force: true })`. The click point (1098, 619) is (686, 387) on the 0.625-scaled screencast frame, where an "E2E Admin created 2 items →" activity toast sat, stacked above the recovery's own toast. A forced click skips the hit-target check, so it clicked the toast's link and the pane left. Main's original failure (the pane ending on another spec's CONVE-868) is the same thing: a toast's item link. editor.focus() reaches the editor without a coordinate. On main's ItemDetail the frozen editor has no tabindex, so focus() leaves it unfocused and the premise still goes red there. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
c5b7c2aa35 |
test(e2e): park the BUG-3697 recovery spec as fixme while BUG-3786 finds why it fails on main (#2137)
Main is red on this spec at
|
||
|
|
a5409ed2e1 |
fix(collab): the editor is read-only through a force_refresh recovery, so no typing is lost (BUG-3697) (#2128)
On force_refresh the provider is destroyed, the tab's text is captured for the recovery notice, and the editor is rebuilt only after the item refetch resolves. The editor stayed editable through that refetch, so typing in the window reached no Y.Doc, missed the recovery copy, and vanished on the rebuild; if the refetch failed it stayed editable indefinitely, against a comment calling it read-only. recoveryFrozen is set in the same task as the capture, before it, and cleared only when the rebuilt provider is wired; the failure arm leaves it set. The editor's editable reads it. e2e bug-3697-typing-during-recovery parks the refetch and asserts the editor refuses input inside the window and is editable again after. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
b6f2d41043 |
fix(server): an account export that times out or fails says so, instead of arriving short (BUG-3626) (#2109)
* fix(server): an account export that times out or hits a store error fails the download instead of arriving short, and says it is complete only when it is (BUG-3626) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(server): the account export deadline bounds a stalled store read, not just the gaps between workspaces (BUG-3626, Codex r1) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(server): the account export hooks live on the Server, not in package variables, so the race detector sees them ordered (BUG-3626, CI -race) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
43d7e05042 |
feat(items): restoring a kept draft applies only your changes, so a newer version is kept (TASK-3607) (#2093)
* feat(items): restoring a kept draft applies only your changes, so a newer version is kept (TASK-3607) A crash draft keeps the body it was edited from (TASK-3606). When the item has been saved since, Restore no longer replaces the whole body. It sends the base-to-draft changes as anchored edits through the patch endpoint (PLAN-3578), which rebases them over the newer writes while every anchor still matches. When one does not (the same text changed on both sides), nothing is written: the notice shows the diff from where the user started and leads with Copy. On success it shows the server's diff of what was applied. draftPatch.ts builds the edits: changed line runs with context widened until every anchor is unique when applied in order, as bodypatch.Apply checks it. It returns null when nothing can be anchored safely. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(items): a restore's continuation is fenced by identity, and a raw tab follows an editor-applied patch (TASK-3607) Codex round 1 on #2093: - an identity change need not move loadGeneration, so both restore paths also check the captured identity after every await; - when an open editor applied the patch, a raw tab drops its seed (unless it has typed since) and follows the row as the flush lands; - the conflict state is keyed by the draft's savedAt and base, not its text. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(items): a failed refresh after a landed restore patch is not reported as a failed restore (TASK-3607) Codex round 2 on #2093: the patch's success is kept once its answer arrives; a failed follow-up read says the changes were applied and asks for a reload. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
8eb4519fe7 |
feat(editor): a tab that dies with unconfirmed edits offers them on the next open (TASK-3606) (#2086)
* feat(editor): a tab that dies with unconfirmed edits offers them on the next open (TASK-3606) The unload prompt and teardown flush never run when a tab dies outright (a mobile process kill, a browser crash). This is the simple alternative to IDEA-3494's Y.Doc persistence: nothing CRDT-shaped is stored. - crashDraft.ts: while the open item holds edits the server may not have (editsAtRiskOnClose, or a dirty raw editor), its markdown is kept every 2s in the kept-draft store with the seq and body it was edited from. It is cleared only when the same item reports nothing at risk and the kept text is still its own. Switching items clears nothing. Bodies over 1M characters are not kept, and nothing is kept during a confirmed sign-out discard. - refusedRawDraft: optional baseSeq/baseBody (back-compatible). A full store sweeps expired drafts, evicts the user's oldest other one, and retries once. Storage stays localStorage: on :7777 the largest body is 158K chars (about 316 KB UTF-16) against a ~5 MB quota (design note on TASK-3606). - The next open offers it through the existing notice and expected_seq restore. The notice is producer-neutral now. When the item has moved past the draft's base seq, it warns that restoring replaces the newer version and makes Copy the primary button (lead ruling). baseBody is stored for the follow-up "apply my changes" restore. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * perf(editor): the crash-draft keeper serializes only when the document changed (TASK-3606 codex r1) Every tick while edits were at risk serialized the whole document, even an unchanged one. The provider now counts every doc update (docVersion, local and remote), and the keeper skips serializing while that version, or a raw editor's pending text, matches its last write. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(editor): a crash draft is never filed under a user who did not type it (TASK-3606 codex r2) The keeper keys drafts by the signed-in user. After an identity change, and before the reload re-stamps identityEpochAtLoad, the editor still held the previous user's text. The snapshot now returns nothing unless the identity is the one the item was loaded under, the teardown flush's own fence. The identity listener already keeps that text for its author. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(items): the identity-epoch census names the crash-draft snapshot's fence (TASK-3606) The page-load epoch is read at six sites now. The sixth is crashDraftSnapshot's guard, which keeps a previous user's typing out of the next user's draft key (codex round 2 on #2086). Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
f3f90d0366 |
feat(billing): the billing page says what Pro covers and who pays (TASK-3603) (#2084)
* feat(billing): the billing page says what Pro covers and who pays (TASK-3603) A buyer could not tell whether the price covered them, one workspace or their team. Checked against the code first: workspace limits follow the workspace owner's plan (store/limits.go), and the workspaces quota counts only workspaces you own. - The price says per what: "$8 per account / month". - Before the purchase controls, the outcome, then who it covers: "Pro covers the workspaces you own. People you invite to them don't need a plan of their own." - The current-plan line names the limits /api/v1/plan-limits serves (they are configurable) instead of "full access" vs "basic workspace management". The upgrade nudge appears only where commerce is allowed. The compare table's storage and backups rows are unchanged, pending Dave's call on both. Draft copy is on TASK-3603 for Dave. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(billing): the Pro line in the apps states the plan only (TASK-3603 codex r1) "People you invite don't need a plan" now shows only where commerce is allowed, like the rest of the who-pays copy. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(billing): the app-mode test checks the pitch is absent too (TASK-3603 codex r2) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * docs(billing): cite why Pro's "no caps" holds whatever is configured (TASK-3603 codex r3) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * feat(billing): Pro storage reads "1 GB included" and the backups row goes (TASK-3603, Dave's rulings) Dave: Pro is to include about 1 GB with metered overage (PLAN-3605, open); the served 10 GB was a dev placeholder. Until the go-live interim is decided, the Pro storage cell says "1 GB included" in both plan tables. Free's number still comes from the limits API. The "Automated backups" row is dropped: backups are a platform service for every Cloud account, not a plan feature. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
cff306e05a |
feat: a plan-limit refusal is a persistent prompt that names the limit and who can lift it (TASK-3601) (#2080)
* feat(billing): a plan-limit refusal says who can lift it (TASK-3601, server) LimitResult carries the payer: the workspace owner for a workspace limit, the user for a user limit (never serialized). planLimitDetails adds payer_is_you, and payer_name when the payer is someone else (the owner's display name, which members already see). Additive: a result with no payer keeps today's shape. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * feat(web): a plan-limit refusal is a persistent prompt that names the limit and who can lift it (TASK-3601) A refusal was a 6-second toast with a generic billing link, offered to everyone alike. Now: - planLimitPromptModel decides what the viewer can do. The payer, where Pro is sold, gets Upgrade. Anyone else is asked to get the owner to upgrade ("Ask Dana, the workspace owner, to upgrade.") and gets a note to copy. With billing off, or no commerce here (self-host, the mobile apps), it shows the statement alone. - One store (planLimitPrompt) holds the prompt until it is dismissed. showPlanLimitToast keeps its name and raises it, so the 46 callers are unchanged. Inline hosts show it where the action was blocked: invite, create/import workspace (import refusals used to be a generic "Import failed" toast), sidebar quick-add, the mobile capture sheet, and the list's quick-create. Everything else uses the global host in the root layout. It is cleared on an identity change. - Upgrade remembers the blocked flow in sessionStorage (upgradeResume: same-origin path, 1h, one-shot). After checkout, /console/billing offers "Continue: <action>" once the upgrade is confirmed. The action is never re-run for the user. No pad-cloud change. - The no-commerce guard's allowlist names planLimitPrompt.ts. The identity-gate rows for handleInvite, quickCreate and quickCreateInColumn are re-reviewed: the change is to the plan-limit call's arguments only, still under each catch arm's identityHeld. Copy as proposed to the lead, who is showing it to Dave. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(web): an unknown payer gets no upgrade offer (TASK-3601 codex r1) Checkout sells Pro to the current user, so offering it when the refusal does not say who pays could sell the wrong account a plan that does not lift the limit. An unknown payer now gets the statement alone. A test pins that a newer refusal from another flow shows in its own host or the global one, never nowhere. The no-commerce e2e's refusal mock says payer_is_you, as the server now does. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(web): a plan-limit prompt stays with the workspace it was raised in (TASK-3601 codex r2) A route component can stay mounted across a workspace switch, so an invite or item-limit prompt raised in one workspace could show in the next, naming the first workspace's owner and blocked action. The prompt records the workspace route (owner/slug) when raised. A host shows it only there, and drops it once the page has left. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
5e914e79ff |
fix(web): onboarding says "Agent activity detected"; live signals say "connected" and "ready to receive" (TASK-3596) (#2077)
* fix(web): onboarding says "Agent activity detected"; live signals say "connected" and "ready to receive" (TASK-3596)
The onboarding launchpad labelled dashboard.has_agent_activity (historical
evidence that an agent worked in the workspace) "Agent connected". The
push dialog separately distinguishes connected sessions from sessions
accepting pushes, so a user told "connected" during onboarding could find
no agent to push to.
- Launchpad: the step and checklist read "Agent activity detected" ("An
agent has already worked in this workspace").
- Live signals use one vocabulary everywhere: "connected" for a session
that is there, "ready to receive" for one that will take a push (was
"accepting pushes"). This covers the push dialog, the quick-actions
status, the push dispatcher's fallback toast, and their tests.
- e2e/bug-3447 follows the launchpad label.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* fix(web): a standing agent grant reads "already has access", not "connected" (TASK-3596 codex r1)
ConnectWorkspaceModal's suppressed-claim panel described an existing grant
as "already connected". "Connected" is now reserved for live session
signals. Also fixes a stale launchpad CSS comment.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* docs(web): the launchpad's agentActive is past activity, never a live session (TASK-3596 codex r2)
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* docs(web): the last comments that called past agent activity "connected" or quoted the old push copy (TASK-3596 codex r3)
Swept as a population (CONVE-35), not per finding. In the touched files,
"connected" is left only where it means a live session or the user's
act of connecting, and "accepting pushes" only in internal comments
describing the armed state, never quoted as copy.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* docs(e2e): bug-3447's comments say agent activity, not agent-connected (TASK-3596 codex r4)
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
|
||
|
|
910a1aeb8d |
feat(web): batch other actors' creation toasts; only their events count as unread (TASK-3593) (#2073)
* feat(web): batch other actors' creation toasts; only their events count as unread (TASK-3593)
Each item another actor created raised its own toast, and every toast,
confirmations of your own actions included, bumped the bell's unread
count. A busy agent buried the human, and the badge stopped meaning
anything.
- toastStore.showGrouped folds a burst from one source into one toast.
While the toast is still up and the last event was under 10s ago, the
next one updates the toast and its history entry in place and restarts
its clock. The layout's item_created toast uses it, keyed by actor:
"Agent created 18 items", linking to their collection, or to the
workspace if they span several. A single item still reads
"Agent created: <title>" and links to the item.
- show() counts toward unread only with { unread: true }. The
other-actor creation toast passes it; confirmations of the user's own
actions no longer bump the badge.
- e2e sse-toast-quiet CONTROL creates as an agent, so its probe cannot
join another spec's user-actor burst, and it now asserts that three
creations become one toast.
- identityResetRound2's toast counts as unread, so its reset-to-0
assertion is no longer vacuous.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* fix(web): every error toast counts as unread; other kinds opt in (TASK-3593 review)
The first cut counted only the other-actor creation toast. Errors now
count toward the bell's unread badge whoever caused them; any other
toast counts only with { unread: true } (toastStore.showUnread). An
error is never a confirmation, and one raised by something running in
the background (a failed autosave or flush, a page-load fetch) may
auto-dismiss unseen. The rule lives in the store, so a new error toast
counts without its call site having to choose, and the files under the
identity-fence AST gates stay untouched.
The server-busy notice is the one non-error background toast that opts
in. The collab "External edit applied" notice stays uncounted on
purpose: it fires on every agent patch to an open item, and the change
is visible in the document.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* fix(web): a toast burst keeps a bounded summary and is forgotten when its toast goes (TASK-3593 codex r1)
Codex round 1, two P2s:
- A burst kept every event and re-rendered them all on each new one, so a
steady agent grew it without bound. showGrouped now takes a fold and
keeps one running summary. For creations that is foldCreated: count,
first item, and the one shared collection or null.
- A group outlived its toast until the same actor fired again. It is now
forgotten when its toast is dismissed, expires or is evicted.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* test(e2e): hold the control toast while its burst lands (TASK-3593 codex r2)
The control leg's first toast lives 4s. A slow create or SSE delivery
could let it expire mid-burst and start a second toast. Hovering pauses
it (TASK-2202).
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* fix(web): an updated toast burst moves to the front of the history (TASK-3593 codex r3)
A burst's history entry took a new timestamp but kept its old position,
so a toast that landed mid-burst could sit above a newer-stamped entry.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* fix(web): a creation burst is keyed by workspace owner and slug (TASK-3593 codex r4)
Two owners can each have a workspace of the same slug. Keyed by slug
alone, one actor's creations in both within the window merged into one
toast, linking to the first.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* test(e2e): hold the control toast as soon as it shows (TASK-3593 codex r5)
Waiting on the list row before the toast could outlast the toast's 4s
clock. The leg now waits for the toast, hovers it, then checks the row.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
|
||
|
|
edb20ae613 |
feat(web): a filtered collection view says it is filtered (TASK-3592) (#2072)
With the filter panel closed, a filtered view looked like the whole collection: the only sign was a dot on the filter button, and the heading counted every item. - The heading count reads "12 of 84" when filtered. - With filters active and the panel closed, a summary line reads "12 of 84 · status: open · tags: ui · search: “x”". Clicking it opens the panel, and its Clear button works like the View menu's "All". Parent and relation ids show as titles; an unresolvable one shows as the id. - The view trigger (desktop menu and mobile chip) shows the active saved view's name, truncated if long, and falls back to the mode. Helper: lib/collections/filterSummary.ts. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
c62b2b80da |
feat(web): quick capture keeps your place (TASK-3590) (#2067)
Quick capture used to open every item it created, so jotting down an interruption meant a context switch. Now "Add" saves the item, clears the title and keeps focus for the next capture, and a toast names the new ref with an Open action. "Add and open" (Ctrl/Cmd+Enter, or the mobile sheet's secondary button) keeps the old behaviour. Applies to both the desktop sidebar dialog and the mobile capture sheet. The mobile sheet clears only the text it sent, so anything typed while the create is in flight is kept. e2e/bug-3473 needs write-then-open, so it now uses Ctrl/Cmd+Enter. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
ebb2936272 |
feat(web): the mobile view sheet lists saved views and "All" (TASK-3589) (#2066)
On a phone, the "Choose view" sheet offered only List, Board and Table. Saved views and "All" were only in the desktop menu, so a saved workflow disappeared at phone width. The sheet now lists them under "Saved views", using the same handlers as the desktop menu, and marks the default with its pin. Managing views (save, delete, set default) stays desktop-only. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
8d910dc20a |
fix(editor): bold, italic and strike always serialize to markdown that reads back (BUG-3583) (#2057)
* fix(editor): bold, italic and strike always serialize to markdown that reads back (BUG-3583) CommonMark lets a `**` / `*` / `~~` run open only where it is left-flanking and close only where it is right-flanking. A mark whose text starts with punctuation after a letter, or ends with punctuation before a letter, has no delimiter that can do both. Measured on the headless editor with the editor's own parser: every such case lost its mark on reload. tiptap-markdown slides an unflankable delimiter inward (the punctuation silently loses the mark), and skips that for a mark at output offset 0, so a bold run starting a block went out as `**Hello.**World` and read back as literal asterisks. flankingMarks.ts replaces bold, italic and strike (and re-adds code and underline, keeping StarterKit's mark order) in the body editor, the materializer (htmlProseExtensions, so parity holds) and the comment editor: - each run is written with its delimiter and, when it closes, tested against the characters actually around it with markdown-it's scanDelims; a run that cannot flank is rewritten to <strong>/<em>/<s>, which parse back to the same mark (html: true) and render through renderMarkdown's allow-list; - tiptap-markdown's sliding is switched off for these marks only (a guarded patch on prosemirror-markdown's base markString); - a textblock whose output could still mis-pair (a run ending in punctuation before a letter, or merged same-character runs) is parsed back and compared per character; on a mismatch it is re-rendered with the HTML form. Code needs nothing (measured). Fuzz (6000 paragraphs of letters, punctuation and spaces under overlapping marks, links and hard breaks, in paragraphs, quotes and list items): marks lost 0 (old serializer 211-228 per 1500); text altered 0-1 (old 163-172, the rest a pre-existing line-start escaping gap); output changed where the old one already round-tripped: 3 per 1500, all one whitespace placement. Serialization time on 500 paragraphs: equal to main for ordinary prose; the extra parse runs only on the textblocks that can fail. Corpus: a block with the failing shapes is added to the materializer parity generator; corpus.json regenerated; Go materialize tests pass on the new bundle. New e2e: typed bold "Hello." + "World" is stored as <strong>Hello.</strong>World; on main it is stored as **Hello.**World. (A reload of an open tab does not show the defect on main: it re-seeds from the op-log. The stored body is what readers and a pruned editor see.) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(web): backlink snippets drop inline tags; every reader of an HTML mark pinned (BUG-3583) The lead's check before GO: the fix stores runs markdown delimiters cannot express as inline HTML, so every other reader of items.content must show it as formatting or plain text, never literal tags. - Share pages and their collection views: renderMarkdown's allow-list renders <strong>/<em>/<s> (e2e). - The editor seeding from the body (after an op-log prune): bold (e2e). - Search palette snippets: already stripped tags (e2e pins it). - Backlink snippets showed the raw slice, so `<strong>` appeared literally, and the slice could cut a tag in two (`…g>Hello.`). Tags and cut fragments are now dropped. Red on main in the new e2e, green here. - Email/notification templates carry no item content; the workspace and artifact exports hand out the raw body, where inline HTML is valid CommonMark/GFM; nothing syncs bodies to GitHub (read from the code). Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
bc3e23d55a |
feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2) (#2056)
* feat(collab): a patch to an item open in an editor applies as a narrowed replace (PLAN-3578 step 2)
A content-anchored patch to an item a browser editor holds now goes to a
patch-capable tab (one that connected with ?patch_capable=1; an older tab
would read an edits-only request as an empty body). The tab applies it to
its live document as a replace of exactly the top-level blocks it changes,
aligned under the ruled equivalence (equal, or differing only in a link's
title or bold/italic on whitespace; a trailing empty paragraph ignored), so
it merges with typing and leaves cursors in place. It acks with the
canonical before/after; the server records the version with ExternalContent
and answers with the diff and content_state applied_pending_flush. A block
with no equivalent refuses patch_unstable_roundtrip; refusals are counted
in pad_patch_refusals_total{reason}. MCP 0.88 (behavior).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(collab): a room with only a non-capable tab is never sent a patch (PLAN-3578 step 2)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
f7b2558196 |
fix(web): ESC closes the pane before its ItemDetail has loaded (BUG-3148) (#2053)
ItemDetail is imported on demand (TASK-2226), so a cold open shows the PaneHost shell (.item-pane) before ItemDetail exists. The pane-close ESC layer was registered by ItemDetail, so in that window the escape stack held only the list-focus layer and ESC did nothing. Measured with pane-a11y-focus :200's own steps at 4 workers: 14 of 20 runs had the stack top at the list priority, and each of those failed to close; every passing run had the pane layer on top and closed via replace-delete. That is the :200 failure on #2046 (?item= still set at :222). The shell now registers the pane-close layer for as long as it is mounted; ItemDetail no longer does (PaneHost is its only embedded mount). New e2e holds the ItemDetail chunk (found by a string only it contains), presses ESC on the empty shell, and expects the pane to close: red on main (?item= stays), green 5/5. Pane + escape e2e specs 71 passed; vitest 5684/5684; check 0 errors. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
e5997aee09 |
fix(web): an agent's [[REF]] applied to an open tab is a link, not bracket text (BUG-3582) (#2052)
The designated applier handed the raw markdown to setContent, skipping the wiki-link → link conversion the load and the collab seed apply, so every open tab showed an agent write's [[TASK-5]] as literal text until reload. toEditorMarkdown (utils/markdown) is now the one conversion every path that hands the editor a body calls: the load's editorContent, the seed capture, the lazy seed, and the applier. rawCanonical's open-tab prediction (BUG-3540) mirrors the applier, so decideRawStale converts its texts the same way before the await; measured: a title link [[Fix it]] is now stored by an open tab as [[TASK-5]], and the unconverted prediction would have raised a false stale dialog. The stored body survives intact (the item's open question): [[TASK-5]] and [[TASK-5|shown text]] round-trip unchanged through the headless pipeline. e2e: an API PATCH while a tab is connected renders the link and stores the wiki-link. Red on main (no link), green 3/3. Identity gate: four rows re-reviewed (the two force-refresh continuations and the lazy seed moved only by their enclosing context; decideRawStale by the pre-await conversion). The shared $lib/utils/markdown import line is left unchanged (wikiLinksToMarkdown now unused there) because the gate hashes it into ~30 unrelated rows; toEditorMarkdown is on its own line. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
22a32efdb5 |
fix(web): skipped board cards keep their placeholder height, so a scroll to a far card lands on it (BUG-3553) (#2050)
Board cards use content-visibility: auto (TASK-1347) inside a height-constrained flex column that scrolls. With the default flex-shrink: 1, flex layout shrank every skipped card to 0px, so the 80px contain-intrinsic-size placeholder never applied and the lane's geometry for off-screen cards was wrong by a card each. Measured on a 200-card lane: 178 cards at 0px, and scrollIntoView on the last card left the column at scrollTop ~2900 instead of ~24500, the CI failures' figure (2921). A click aimed there landed on another card: the pane-controller :568/:642 pair and the anchors :290 case. flex-shrink: 0 on .card-wrapper. List and table rows do not collapse (measured: their parents are not height-constrained scrollers). New e2e, in its own collection so the shared Docs board gets no cards: 80 cards, none at 0px, and the last card centres and is hit after scrollIntoView. Red on main (61 cards at 0px), green with the fix 3/3. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
d8a44bb5ee |
test(e2e): record the geometry when a board-card click fails (BUG-3553 probe) (#2049)
* test(e2e): record the geometry when a board-card click fails (BUG-3553 probe) pane-controller :583 and pane-content-link-anchors :290 fail on CI with another card intercepting the hit point, and then "not stable" until the timeout. It has not reproduced locally (a CI-shaped desktop shard 1/2 at 2 workers passed clean), so the mechanism has to be read from a failing CI run. clickBoardCard clicks as before; on failure it records the target's box over ten frames, its lane index, the column's scroll, neighbours' content-visibility state, transforms, and the element under the hit point, attaches it, and rethrows. No wait is added. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(e2e): BUG-2871 spec opens its own parent on a retry; its click is probed (BUG-3553) On #2049's first run the spec's first attempt clicked the parent card and no pane opened (the BUG-3553 family), and its retry then opened the FIRST attempt's parent: the locator matched the title prefix and took .first(). It now selects the card by the seeded slug. With --repeat-each=3 on one server the old locator fails exactly as CI did; the new one passes 3/3. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
d6a45bf339 |
feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577) (#2048)
* feat(billing): billing availability and prices come from the sidecar; PAD_BILLING=auto|off (TASK-3577) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * style: gofmt (TASK-3577) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(billing): session offer matrix, contract decode, served-price e2e (TASK-3577) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(web): sweep every user's expired kept drafts at boot (BUG-3571 follow-up) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
46490b0612 |
fix(web): keep unsaved text when the session ends under an open item (BUG-3571, part 2) (#2044)
* fix(web): keep unsaved text when the session ends under an open item (BUG-3571, part 2) Ruling R1. Text an open item can no longer save under its author's session is kept in this browser as a kept raw draft under the author's id, and the existing kept-draft offer and its version-checked restore give it back: - an identity change the tab did not start: the item's identity listener keeps the previous user's text first, before it retires and reloads; - a session-expiry 401: every redirect to /login in the API client runs the open items' keepers before it navigates (the redirect does not move the identity epoch, and the unload flush after it goes out on a dead session). Rich text is kept as the markdown a flush would send (collabFlusher.snapshot). Kept drafts now expire after seven days, and an explicit sign-out purges all of the user's. A confirmed sign-out discard still keeps nothing. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(web): e2e for the keep on session expiry (BUG-3571, part 2) Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
1eecf710c8 |
fix(web): angle-bracketed prose survives the editor: <Button>, Promise<T>, comments and unknown tags are text, not dropped markup (BUG-3557) (#2035)
The editor parsed markdown with html enabled, so `<Name>` in prose was a tag the schema could not hold: dropped on the next flush (one keystroke, or an agent's write applied by an open tab, which answered 200 while the text vanished). `<Table>` became an empty table that was then removed. htmlProse.ts derives the allowlist from the schema's own parse rules, so the HTML the editor itself writes (tables with spans, HTML-only marks) still reads back: - an inline tag passes only if written lower case, matching an inline node's or a mark's rule, and closing one that passed; anything else is literal text; - an HTML block passes only if every tag in it matches a rule; otherwise it is kept whole as an HtmlBlock (stored as a ```html fence); - comments are visible text. PadText escapes `<` only where the parse would read a tag, so `Promise<T>` round-trips byte for byte. Both the editor and the headless materializer take the same extensions, right after StarterKit. materializerParity now skips and counts cases where the two live tabs converge to different documents (BUG-3568, pre-existing: 2 of 300 seeds on main), at most 2 of 50, and still requires the materializer to match one of them; such cases stay out of the Go corpus (lead ruling). Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
5551e9fd52 |
test(e2e): mermaid and the graph view draw on the embedded bundle, each with a counterfactual (TASK-3552) (#2029)
* test(e2e): mermaid and the graph view draw on the embedded bundle, each with a counterfactual (TASK-3552) Dependabot moves mermaid and 3d-force-graph, and nothing checked either still paints. Each leg measures through one helper, run where the thing must be drawn and where it must not: a two-node flowchart vs invalid source, and two linked items vs an empty workspace. The graph measure hides everything over the WebGL canvas, the renderer's own navigation hint included: that text alone is ~120 colours and made an empty graph read as drawn. Receipt: drawn 237-264 colours, empty exactly 1, against a bound of > 3. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(e2e): TASK-3552 legs write to workspaces of their own, not the shared one (BUG-3553) Docs created in the suite's shared workspace reflow its docs board under other specs' clicks; pane-content-link-anchors :290 was intercepted that way on this PR's first CI run. Each leg now makes its own workspace. Receipt re-measured there: drawn 198-225 (desktop) and 1134-1146 (mobile) colours, empty 1 and 2, against a bound of > 3. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
1fc60d72b4 |
fix(web): a save refused for an open tab's unconfirmed typing asks, and Overwrite lands (TASK-3548) (#2025)
* fix(web): a content save refused for an open tab's unconfirmed typing asks, like a pending-flush refusal (TASK-3548) Since BUG-3542 a content write to an item open in another tab answers 409 content_not_applied, apply_reason unconfirmed_edits, while that tab holds typing the server has not stored. No content-write site recognised it: the item pane's debounced raw save showed 'Failed to save content', its teardown flush said nothing, and the rich fallback, the kept-draft restore, the raw flush, the conventions page and the playbook editor all fell through to their generic failure. isEditsNotStoredRefusal() is either refusal; all seven sites use it, so each asks 'Unsaved edits in an open tab' and Overwrite resends once with overwrite_pending_edits (which lifts both), and the teardown reports the pending-edits sentence. Other content_not_applied arms keep the generic path. Identity gates re-reviewed: ItemDetail's rows moved because the units they call changed; the keepalive row's pinned code names the new helper; the playbook save() row is re-read. * fix(web): Overwrite after an unconfirmed-edits refusal lands (TASK-3548, option B) An unconfirmed-edits refusal still commits its row write (it versions the old body first), so seq moves under the caller's token and the overwrite resent with it met update_conflict: the user chose Overwrite and the save did not land (codex). On that refusal each token-carrying site now re-reads the item by id, behind its fence, and when the stored body is still the one the token was taken against (stillOnBase) resends with the fresh token. The raw paths also keep the Overwrite answer armed across a silent rebase. task-3548-overwrite-lands.spec.ts pins it at every token-carrying site (raw save, raw flush, kept-draft restore, conventions, playbook editor): the route makes a real non-content change (seq moves) before answering the refusal, the overwrite then goes to the real server, and the body is read back. All five legs fail on the previous commit. * fix(web): the fresh-token overwrite requires the row to hold what the save sent (TASK-3548, codex) The playbook save sends its title and the conventions save its metadata; re-sending them with a fresh token would put back a value someone else changed after the refusal. holdsWhatWasSent requires the re-read row to hold the sent title and every fields_patch value, else the save meets the conflict as before. The conventions save fences its re-read on workspace and identity. New leg: a title someone else set is not restored (it was, without this). |
||
|
|
c4354571d0 |
fix(web): keys typed before the slash or [[ menu opens are its query (BUG-3547) (#2024)
* fix(web): keys typed before the slash or [[ menu opens are its query (BUG-3547) Both menus open in a deferred tick (so the caret rect is read after the keystroke lands), and the query was tracked only while the menu was open. Keys typed before that tick were lost: under load '/link' opened the UNFILTERED list and Enter ran its first entry, which is the task-2218 :77 flake (4 of 30 at 6 workers; the trace showed '/link' deleted, no '[[', no picker). The deferred open now reads what is already typed after '/' or '[[', with the same no-match close. The new task-2218 leg freezes the page clock, types '/link' before the open, then releases it: one entry, and Enter opens the picker. It fails on main every run. The spec passes 75/75 at 6 workers. * fix(web): the deferred [[ open checks its trigger is still there (BUG-3547, codex) |
||
|
|
4c733467ef |
fix(web): space between the pad wordmark and the first workspace tab (TASK-3545) (#2021)
* fix(web): space between the pad wordmark and the first workspace tab (TASK-3545) The desktop top bar's tab strip started 72px from the left, about 6px past the end of the 'pad' wordmark (7.1px measured). It now starts at 82px: 17.1px measured at 1280 and 820, light and dark. The mobile header has no wordmark or tab strip, so it is unchanged. task-3306's spec pins the gap at >= 12px (it fails on the old layout), and runs its six-tabs-at-200px leg at 1460 instead of 1440: six tabs had about 4px to spare there, and the gap took 10. * fix(web): the logo sits in the desktop top bar's row, so the gap is fixed (TASK-3545) CI measured 10.9px where local measured 17.1px: the wordmark uses the system font stack, and with the logo absolutely placed and the strip at a fixed offset, the space left over depended on how wide the font drew 'pad'. The logo is now in the row on desktop: the first tab starts the bar's 8px gap plus the list's 9px flare inset after it, 17px whatever the font. The spec widens the wordmark and asserts the gap does not move; it fails on the fixed-offset version. |
||
|
|
186ffdba63 |
fix(web): the remaining native confirm() dialogs ask through the shared ConfirmDialog (TASK-3543) (#2018)
* fix(web): the remaining native confirm() dialogs ask through the shared ConfirmDialog (TASK-3543)
Settings (remove member, cancel invitation, give up owner role), roles
(delete role) and the collection create/edit discard guards use
confirmDialog. Each captures identity and workspace (and the role, or
the edited collection) before the dialog's await and re-checks after
it; the collection guards are async now, ask one question at a time,
and do nothing when the modal closed meanwhile. ConfirmDialog takes an
optional cancelLabel ('Keep invitation', 'Keep editing').
The beforeNavigate confirms (ItemDetail, playbook editor) stay native:
they must block synchronously.
* test(web): a self-demotion answered after a workspace switch sends nothing (TASK-3543, codex)
* test(e2e): the collection discard question is the app dialog now (TASK-3543)
task-2186's legs answered the native confirm() through page.once('dialog').
They now answer the app's ConfirmDialog, and match the collection modals
exactly: 'New Collection' is a substring of 'Discard this new collection?'.
|
||
|
|
5960cf1978 |
fix(collab): an open tab refuses a content write over typing the server has not stored (BUG-3542) (#2016)
A content PATCH routed to an open tab (the designated applier) replaced typing that was still on the wire. The BUG-3133 pending-edits check reads the op-log when the PATCH arrives and cannot see it. The server now marks the applier_request `guarded` unless the caller sent overwrite_pending_edits. A tab whose edits may be missing (WsProvider.editsMayBeMissing: unsent, catch-up owed, or sent without a barrier ack) answers applier_refuse instead of applying. The server takes that as final (no re-election) and the PATCH answers 409 content_not_applied with apply_reason unconfirmed_edits and content_landed false; other fields land, as on every content_not_applied. Tokenless writes are refused too, by ruling; overwrite_pending_edits is the only escape. ToolSurfaceVersion 0.84 (BEHAVIOR bump). docs/api.md and docs/mcp.md updated. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
77b2c256fd |
feat(web): list group headers get the board lane's bulk actions (TASK-2222) (#2012)
* feat(web): list group headers get the board lane's bulk actions (TASK-2222) From the 2026-07-20 UX audit (C96): "tag/assign/set priority on everything in the Open group" was one menu action on the board but needed a view switch from list and table, and list users never discovered bulk ops. Each list group header now carries the board's ⋯ LaneActionsMenu (move all, tag, untag, set priority, assign) beside its archive button, on the group's currently-filtered items, through the same bulk handlers and the same canBulkEdit gate the board uses. Move all is offered only where the board offers it (a bulk-movable group field, never a relation grouping). The header is a role=button that toggles collapse, so it ignores clicks and keys that start inside the menu (the menu's buttons stop propagation; its panel and separators do not). An open group's .item-group is overflow:visible, so the menu is not clipped in a short group. The ⋯ is always visible: 28px, and 44px at touch widths (TASK-3311). Archive keeps its own button, which the TASK-3311 spec pins. The table view is not in this change. * fix(web): the list group header is a toggle button with its actions beside it (TASK-2222) Codex round 1: the header was a role=button div, so the new ⋯ button (and the existing archive button) were interactive controls nested inside another. The header is now a plain div whose empty space still toggles on a click (as before, and as the BUG-3492 spec clicks it); the keyboard and assistive-tech control is a real <button class="group-toggle" aria-expanded> holding the collapse icon and title, and the count, archive and ⋯ menu sit beside it in .group-actions, whose clicks the header ignores. Tests pin the structure (no role on the header, no button inside the toggle, the toggle and the header space both toggle). * fix(web): no group drag while a list group's menu is open (TASK-2222) Codex round 2: the menu sits inside a draggable group (one dndzone over the groups), so a press-and-drag on it could reorder the groups mid-action, and a drag ending outside could close it. The group zone's dragDisabled now also holds while any group menu is open. dnd-action's zone config is not observable in jsdom, so this is not unit-tested; the collection suites pass. * fix(web): the list group ⋯ clears the archive button's touch target (TASK-2222) CI (E2E mobile, TASK-3311 touch-targets spec): 24 points of the archive button's 44x44 area landed on the new ⋯ group-menu button, whose box started inside the archive extender's ~12px overhang. On touch widths the ⋯ now has a 14px left margin, past the overhang; the archive comment no longer claims the header holds no other control. The spec also checks the ⋯ is 44x44 and takes none of the archive area. * fix(web): the ⋯ clearance margin goes on its wrapper, outside every box (TASK-2222) CI again: the margin on the button widened .group-menu-wrap over the archive extender's overhang, and the wrapper took those 24 points. A margin on the wrapper itself sits outside every element's box. |
||
|
|
edf7e22262 |
fix(web): a raw markdown save carries its base and asks, not replaces, over a changed body (BUG-3540) (#2011)
* fix(web): a raw markdown save carries its base and is asked about, not applied, over a changed body (BUG-3540) A raw save was a blind replace. Tab B's raw text is seeded before tab A types; once A's edit is STORED, nothing is pending, so refuse_pending_edits let B's save replace it (reproduced 3/40 under load, now deterministic in e2e). Raw saves (debounced, keepalive, toggle drain) now send expected_seq: the row the raw text was seeded from (rawBase.ts), moving with each landed save. A 409 update_conflict reads the item: a body this tab already has EXACTLY (its base or text it sent; no looser match, lead ruling) rebases and resends once; anything else asks, in the pending-edits dialog's shape, with no default: Reload the stored text, Overwrite with my text, or close (send nothing). An unload save that is refused stays kept in the browser and is offered on the next open, as for pending edits. No server change. ItemDetail identity gate: decideRawStale/resolveRawStale tabled and fenced (the gate caught both missing fences); re-hashed rows re-reviewed, block note. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): a raw save beside an open rich tab is not asked about its own text (BUG-3540) With a rich tab open, a raw save goes through that tab's applier and its next flush stores our text in the editor's form (BUG-2995: '* ' bullets become '- '). Measured: bullets drew the stale dialog on 2 of 3 saves. A refused raw save now also compares the stored body, EXACTLY, with the form an open tab would store for each of this tab's own texts: rawCanonical.ts runs the applier's setContent and the tab's flushPipeline on a headless editor (loaded on demand). The materializer's headless editor, its extension list and flushPipeline move to materializer/headless.ts (no goja shims, no atBlank patch); the bundle entry imports and re-exports them, so the bundle is unchanged in behaviour, and the browser never installs the atBlank patch on a live tab. e2e: three raw saves beside a rich tab ask nothing, plain and bullets (0 of 3 each; bullets was 2 of 3 before). Go internal/materialize passes on the rebuilt bundle. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
88a26afde0 |
feat(web): reference children are listed apart and leave progress (PLAN-3535 PR 3) (#2005)
- childProgress: isReferenceChild / splitReferenceChildren; countChildProgress and countedChildren leave reference children out; childRowState is the per-row done styling (a wrong-shape value read as its lane reads it, BUG-3052); statusState judges a status tally by the collection's rule. - ChildItems: a References group below the status groups (no done styling, no reorder), progress bar, chart and fallback count over work children, print view likewise; per-row done styling asks the hub instead of a hardcoded/inherited terminal list. NestedChildren the same. - Dashboard collection cards: the done rule per collection instead of a hardcoded list; no progress bar for a reference collection. - Insights picker: filters on tracks_work instead of is_system (the server's default report rule since PR 2). - Web classification guard over every lifecycle-helper call and any hardcoded done list; unit and component tests; e2e for the parent page. ItemDetail still passes terminalStatuses (now unread): removing it re-flags every unit of ItemDetail's identity gate, so it is a follow-up. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
23c8e4f811 |
feat(collab): the GC compacts dormant op-logs and a covered resume merges (TASK-3531 U3-U5) (#1985)
* feat(collab): the GC tick compacts dormant op-logs and Join admits a covered resume (TASK-3531 U3-U5) The second half of TASK-3531, behind PAD_OPLOG_COMPACT=on (default off). U3: with a compactor set, each op-log GC tick first compacts every dormant, flushed op-log into one Yjs snapshot (#1984's primitives): the snapshot is built on the materializer worker WITHOUT the item lock, then swapped in under the lock with no room open, and CompactItemOpLog re-checks the log is the one it read. The sweep then runs as PruneSweepKeeping and keeps compacted logs. Anything not compacted (a failed job, a refused swap, set-aside rows, another schema) is left to the sweep, which deletes it as before. cmd/pad builds the materializer before the GC starts and hands it over as the compactor only when PAD_OPLOG_COMPACT=on and the materializer is enabled. U4: Join admits a resume cursor the snapshot covers (CompactedResumeCovers, which requires the snapshot row to exist) instead of force_refreshing it; the tab replays the snapshot and its own edits merge. A failed check refreshes. Telemetry for the default flip (lead ruling): pad_oplog_compactions_total {outcome}, pad_oplog_compaction_snapshot_bytes_total against pad_oplog_compaction_markdown_bytes_total, pad_collab_compacted_resumes_total. U5: server tests (compact then keep, the no-compactor and failed-job deletes, a room opened mid-job blocks the swap, a covered resume admitted vs refreshed) and an opt-in e2e (a tab asleep across the sweep merges, or with compaction off is handed its text back). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(server): cap compaction jobs per GC tick and keep the deferred items (TASK-3531, codex) Compaction shares the materializer's single job slot with op-log recovery, which carries unflushed edits, so a dormant backlog must not hold it for a whole pass. At most 25 snapshot jobs a tick; items past the cap are kept from that tick's sweep (deleting them would lose their chance) and compacted by a later tick. The opt-in e2e's mode=off timeout now names the likely cause. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(materialize): updates built on pre-compaction structs merge onto the snapshot (TASK-3531) The merge property in CI, not only in the opt-in e2e (lead): over the corpus, a snapshot of the first k rows followed by the rows after k materializes to the full document (150 cases). Those later rows depend on the prefix's structs (lone, 114 of them are pending), so this is the dependency resolution a deleted-and-reseeded log fails. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
4ac7f16084 |
feat(web): gapped sort keys, so a reorder is one write (TASK-3525) (#1975)
* feat(web): gapped sort keys, so a reorder is one write (TASK-3525) Lane order was dense integers, so a drop between two cards renumbered one side of the lane: up to ~510 rows (and ~510 'reordered' activity rows) for a middle drop in a 1,020-card lane. The planner now keeps the longest run of cards already in order and writes every other card into the gap between its kept neighbours: 1,024 past it at an end, spread evenly in the middle. Moving one card in a gapped lane is one write. With no room (a dense lane from before this, a never-dragged lane of 0s, or a gap closed by repeated drops in one spot) it shifts the shorter side as before, but 1,024 apart, so the next drops are single writes again. A plan leaving +/-2^30 re-spaces the lane around 0. Client only: the server stores whatever integers it is sent, so no schema, API or MCP change, and API writers keep working with any values that sort. The roles board shares the planner and gets the same behaviour. Also: the timeline says "moved up/down" instead of raw sort values; the share page's comment about its order is corrected (it is ListItems' default, not sort_order); CLAUDE.md says a copy does not carry sort_order; the CLI's --sort-order help says it is a key, not a position. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): a reorder writes the card the user moved when that is enough (TASK-3525) A one-write plan can often write either card of a swap, and each written card gets the 'reordered' activity row. The board, list, table, child list and roles board now hand the planner the moved card's id, and it is written alone whenever every other card already sorts and its gap has room. The identity-gate rows for handleReorder and handleDndFinalize are re-reviewed: the id is a synchronous planner argument, nothing moved. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): the reorder row shows a direction only for two differing numbers (TASK-3525, codex) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
a867a8f774 |
fix(web): icon-only buttons carry accessible names (TASK-2240) (#1974)
* fix(web): icon-only buttons carry accessible names (TASK-2240) The open parts of audit C111, except the ItemCard star (it lands after #1968, which rewrites that file): - Mobile editor toolbar: the list buttons were named by their glyphs (•, 1., ☐) and B / I / S / H2 / H3 / code / quote by their letters. Each now has an aria-label ('Bulleted list', 'Bold', 'Heading 2', 'Code block', 'Quote', ...), the toggles say whether they are on with aria-pressed, and the toolbar is named 'Formatting'. Outdent, Indent, Insert block and Attach file had only a title; they keep it and gain an aria-label. - EditorLinkPopover's open / edit / remove: aria-label beside the title. - The create-workspace modal's ✕ and the roles page's three dialog ✕s were unnamed: 'Close', with the glyph aria-hidden. * fix(web): the card star is a named toggle (TASK-2240) The last C111 part, after #1968 rewrote ItemCard: the star had only a title. It is now aria-label 'Star <title>' (constant, so a list of cards does not read 'Star, Star, Star'), aria-pressed carries the state, and the glyph is aria-hidden. An e2e leg finds it by that name and flips aria-pressed both ways. |
||
|
|
9103aaeb24 |
fix(web): section tab titles, one Mod+\ chrome state, a reachable and complete shortcuts sheet (TASK-2261) (#1972)
The three parts of audit C99/C101/C102 still open:
- C99: Starred, Tags (and a tag page, '#tag'), Library, Settings, Roles,
Conventions and Playbooks now name their section in the browser tab,
with the same one-liner the activity page uses. They read
'{Workspace} · Pad', the same as the dashboard.
- C101: Mod+\ toggled the sidebar and the workspace bar independently,
so with one hidden it showed that one and hid the other. It is now one
chrome state (uiStore.toggleChrome): either bar open closes both, both
closed opens both. On mobile, where the workspace bar is not rendered,
only the sidebar moves. The four bar tooltips and the sheet say so.
- C102: the shortcuts sheet opened only with the undocumented '?'. The
account menu has a 'Keyboard shortcuts' entry (the sheet's state moved
from the root layout to uiStore so it can), and the sheet now lists
Mod+F (filter the list), Tab into the open item pane, and the palette's
go-to by number or ref.
|
||
|
|
bf61e5fb94 |
test(e2e): the board-drag leg brings its card into view first (TASK-2237, after BUG-3527) (#1977)
Main's E2E desktop job fails 'a board drag started on the title moves the card' (the card never entered the Done lane, first try and retry). Since #1969 a new card joins the END of an un-dragged lane, and in the suite's shared workspace the Open lane holds enough cards to put the test's card below the fold (CI page snapshot: last of the Open column), so the gesture started off-screen and no drag began. The leg now scrolls the title link into view before measuring it, so it no longer depends on how many cards the shared lane happens to hold. Reproduced locally: 20 filler tasks created a second before the card fail the unfixed leg with the same error, and pass with this change. |
||
|
|
b0f325e751 |
fix(web): hand back an edit lost with its socket on force_refresh (BUG-3526) (#1973)
* fix(web): hand back an edit lost with its socket on force_refresh (BUG-3526) TASK-2199 hands a tab's text back when a force_refresh discards its Y.Doc, but it keyed only on edits made while no socket was open. An edit that went out on a socket that died before the server stored it, followed by an op-log prune, was dropped without a word. The provider now exposes editsMayBeMissing (unsentLocalEdits, or a send no barrier confirmed on the current or a closed socket, BUG-3523), and the page hands the text back whenever it is true. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): read the handed-back version from its textarea (BUG-3526) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(web): re-review the force-refresh rows for the new capture input (BUG-3526) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): prove the lost edit went out and was dropped (BUG-3526, codex) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
cbf90f4542 |
fix(web): cards, sidebar rows and table headers stop nesting controls (TASK-2237) (#1968)
* fix(web): cards, sidebar rows and table headers stop nesting controls (TASK-2237) The three parts of audit C29 still open: - ItemCard was an <a> wrapping real buttons (PR badge, copy-ref, star, status and priority pickers, tags, the reorder menu): invalid markup, a link named by the whole card's text, and controls assistive tech flattened. The card is now a div; the title is the link, and its ::after covers the card, so a click anywhere that is not a control still opens the item (plain click to the pane, modifier and middle clicks to the full page). Every control is a sibling raised above the overlay, and the card shows the focus ring when its link has focus. - A sidebar collection row nested its quick-add button inside the nav link. The row is now a wrapper holding the link and the button as siblings (the wrapper is the drag item); the button sits over the link's reserved right padding and is opaque on row hover or focus. - TableView's sorted header carries aria-sort (ascending/descending) on the active column only; the arrow glyph is aria-hidden. Two consumers asked for the card anchor and now ask for its link: the pane's return-focus target (which would otherwise pick the PR badge, first in DOM order) and the list-to-pane Tab bridge. E2E specs that clicked or read the card element follow the link. * test(e2e): a drag started on a card's title moves it, and starts no native link drag (TASK-2237) Lead review on #1968: the card's whole surface is now an <a> (the stretched ::after), and an anchor can start a native link drag that fights svelte-dnd-action's pointer drag. Measured: it does not. dnd-action preventDefaults the drag item's mousedown, so no dragstart fires at all (0 seen with every dragstart counted). The legs pin it: a board drag started on the title text lands in the Done lane and stores status=done, without opening or navigating, and no un-cancelled dragstart is seen on a board card or a sidebar row. The probe counts in the capture phase and reads defaultPrevented after dispatch; on a plain link outside any dndzone it counts 1. The quick-add leg reaches the button by Tab from its own link: Firefox does not match :focus-visible after a programmatic focus(). |
||
|
|
0b2996114e |
test(e2e): the quick-add picker's clip check measures the dialog's clip, at any collection count (BUG-3529) (#1970)
The leg hit-tested the picker's LAST OPTION. The picker scrolls its list past 280px, and the suite's shared workspace gains collections as other specs run, so under full-suite load that option was scrolled out of the picker and the hit missed: reproduced deterministically by adding 15 collections first. Not a transition, which BUG-3529's first hypothesis guessed (the picker has none). The check was also vacuous at the fixture's own size: with four collections the last option ends inside the dialog's box (319 against 324), where no clip can show. It now hit-tests the PICKER's bottom edge, which reaches 33px past the dialog, and requires that it does. Both go red on a dialog forced to overflow:hidden (it scrolls its content to keep the focused picker in view, leaving the picker -0.2px past it); scrolling the last option into view instead would have hidden that, since scrollIntoView scrolls the dialog too. |
||
|
|
f323d810a6 |
fix(web): manual order breaks a sort_order tie oldest first, as the server does (BUG-3527) (#1969)
Every create path writes sort_order = 0, so ties are the normal state for any lane or child list nobody has dragged, and the two sides broke them in opposite directions: the server (getChildItems, GetChildItemsForParents) orders created_at ASC and child lists and the dashboard render that as delivered, while the web comparator's manual mode broke ties created_at DESC. The same two children read in opposite orders on an item's page and on its collection board. Lead ruling 2026-10-09: the server's order wins. itemComparator's manual tie-break is now created_at ASC, then id. A new item joins the END of an un-dragged lane; dragging still places it anywhere. Unit tests and the BUG-3492 j/k spec (which pinned the old on-screen order) follow; the spec still discriminates, since the page holds updated order and from 'old' that says 'c' while the screen says 'b'. |
||
|
|
416aea2d14 |
fix(collab): stop appending the whole document to the op-log on every reconnect (BUG-3523) (#1965)
* fix(collab): reconnect catch-up only when this tab's frames can be missing (BUG-3523)
The provider sent the whole document as one update on every reconnect,
appending a full copy to the op-log on each laptop wake or network blip.
It now sends it only when an edit was made with no socket open, or a
local update went out on a socket that then closed and no barrier ack
cleared it.
The barrier: once local sends go quiet (2s), the client sends one
`barrier {n}`; the server answers `barrier_ack {n, ok}` in readLoop
order, after every earlier frame was persisted, with ok=false when an
op-log append on that conn failed since the last barrier. An ok ack
covering every local send clears the debt; ok=false keeps it and logs
the item; a server that never answers leaves the tab owed.
Also counts materializer give-ups (pad_materialize_giveup_total{kind}).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(collab): a SyncStep2 answer is stored but not counted as content (BUG-3523)
A step2 is y-protocols' answer to a peer's step1 and repeats content other
rows carry: the answering tab's own edits went out as updates (and are
repaired with a full catch-up if they may be missing), and everything
else it holds came through the relay. Counting it kept items pending and
made the materializer replay a full copy of the document per joining tab.
Measured first: recorded multi-tab op-logs, across a dormancy prune,
materialize to identical text with and without their step2 rows.
Applied at append only. The startup backfill keeps the older rule for
legacy rows, written by clients with no failed-append repair.
Also: a barrier_ack with ok=false now resends the document on the live
socket at once (lead ruling), instead of only owing the next connection.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(collab): say what the barrier ack's write deadline bounds (BUG-3523)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(e2e): take the server's data dir from one module (BUG-3523)
The step2 spec resolved .pad-e2e one level too shallow: it passed where
PAD_E2E_DATA_DIR was set and could not open the database in CI.
playwright.config.ts and the spec now read e2e/lib/data-dir.ts.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
9a90368f66 |
fix(web): one connection-recovered signal heals failed loads, and the dashboard and activity show stream status (TASK-2201) (#1960)
* fix(web): one connection-recovered signal heals failed loads, and the dashboard and activity pages show stream status (TASK-2201) * fix(web): a stream recovery waits a random 0-5 s before pages reload (TASK-2201, lead review) |