Repository navigation
Conversation
Background refreshes (focus, visibility, websocket, permission hints) used to drop the capability to undefined, which unmounted the Permissions panel's space creation section and discarded typed search text, pickers and loaded rows. The hook now keeps the last confirmed capability for the same scope while a refresh is in flight or after a failed refresh, exposes `revalidating`, and drops it only for an unsupported route or a scope change. Admission stays fresh: `canCreateSpace` is true only for a current ready answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refresh that finished after another tab rotated the shared token used to throw and route every caller to invalidToken(), signing all tabs out. The refresh now adopts the stored rotation for the same account, treats sign-out, another account and a server switch as a superseded session (never applied, never signed out), and only ends the session when GoTrue rejects the refresh token itself; offline, timeouts and 5xx keep the session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workspace permission hint received while the current user was still loading was recorded as processed and then skipped as a retained old-transport message once the session became identified, so open capability caches were not re-probed. Nothing is recorded until the session is identified; messages processed by a previous session are still fenced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts discardable A definitive rejection of the create request (400/403/422, validation, B denial) now forgets the saved request, keeps the fields editable and rotates the client-owned IDs, instead of freezing the dialog behind a Retry that resent the identical payload. Ambiguous outcomes keep a resumable request with plain-language "Check again" and a confirmed "Discard". A committed space whose people could not all be added offers "Try again" or "Add later", and Close no longer traps the user. Unreadable saved data reports the translated recovery error instead of a raw SyntaxError, and both the dialog and the sidebar offer Discard. The sidebar entry is styled like the other sidebar actions. Creation on a server without the capability route lets the server decide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The account key for app_view_cache and workspace_database_catalog became server scoped without a schema change, so rows written by earlier builds were never read, refreshed or evicted again. Version 14 deletes only those pre-scope rows and keeps current server-scoped entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d-page rule
SpaceSidebarActions and ViewActionsPopover called useSpaceActionPermissions next to
useViewActionPermissions, which already owns that hook, so every hover, menu open and
permission notification issued two identical GET /spaces/{id}/permission requests.
useViewActionPermissions now exposes canOpenManageSpace and canEditSidebar from its single
read and the second call is gone.
On a server without the structured space routes (404/405) the sidebar policy does not
exist, so direct-child creation falls back to the old Write-only rule instead of hiding
the space "+" button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rity Duplicate places the copy beside its source and Move adds the page under the chosen destination, and the server requires the destination's Write plus, for a space root, its sidebar authority (P). The page menus only checked the source page, so editors were offered Duplicate in owners-only spaces and Move into any space root, then got a server rejection. A new usePageDestinationAccess hook reuses useViewActionPermissions for the destination. The sidebar and page-header menus hide Duplicate while the parent cannot accept a new page, and Move keeps its button disabled with a visible reason until the selected destination is confirmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The access-override preflight fetched the space capability, then the roster, then sent the PATCH: three sequential round trips before a member's access could change. Both reads are independent, so they now run concurrently. The capability result is still checked first, so a lost management right keeps reporting the clearer "can't be changed" error, and no write is ever sent unless both reads allow it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is unavailable New Page listed every space and only learned from the server that a member without Write, or without sidebar authority in an owners-only space, could not add a page there. Each space is now checked when the dialog opens (one space-permission read per space, bounded concurrency, re-checked on permission notifications without blanking results). Spaces that cannot accept a page are disabled with the reason, Add stays disabled while the selected space is checked, and a note explains when no space is available. Unchecked spaces (transient errors, old servers) stay selectable and the server decides. The disabled "Create a new space" link and "Duplicate Space" item explained themselves only in a title attribute that disabled controls never show, and mislabelled failures as loading. Both now show the reason inline (checking, denied, or a failed check with Try again). A server without the capability route keeps both entry points enabled and the server decides, instead of a permanent "ask your administrator" dead end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server pushes a PermissionChanged notification to every workspace member, including the person who just changed something in this dialog, and it does so before returning the HTTP response. Manage Space reacted to every such notification by blanking the whole dialog (roster, capabilities and settings) and reloading it, so each change was followed by a full reload, and the success toast of the change was dropped because the reload superseded it. A notification now rechecks settings that are already on screen in the background: the last confirmed data stays visible, controls whose authority is being rechecked are disabled, and the name being typed is not interrupted. If the recheck fails, the data stays visible but every control stays blocked (fail closed). Opening the dialog and type changes still load from blank. A settings change the server accepted is still confirmed when the notification superseded its response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A creation request ID was saved before admission and kept after every failure, so a 400 (name too long), a 403 or an offline/denied pre-check left a read-only "Resume" request that could never succeed or be discarded. The request is now saved only after fresh admission passes, immediately before the keyed POST. Definitive answers (validation, denial, conflict, seat limits, 400/401/403/409/422, or a first send that reaches a node without the keyed route) release it; only outcomes that may have committed keep it for a replay with the same request ID. Corrupt stored values report a translated message and can be discarded. A server without the capability route is treated as a legacy server for every hosting mode: creation stays available and uses the original POST /api/workspace without a request ID, so the client never advertises creation and then calls a keyed route the server lacks. Replays skip the client admission check (the server resolves receipts first) and never fall back to the unkeyed route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every focus, visibility change, socket transition and permission hint reset the capability to loading, so Create flickered to disabled and the dialog's Create button toggled mid-typing. The last confirmed answer now stays visible while it is rechecked (only the first answer shows loading), focus/visibility rechecks wait 30 seconds after a confirmed answer, socket transitions only recheck once the socket is open, and a failed background read keeps the confirmed answer unless the browser is offline. A lower policy revision used to become a permanent error that every Retry repeated. A background read with an older revision is now ignored, while an explicit Check again accepts it, so a server restored to an older revision cannot wedge the control. The hook also reports a legacy server and the progress of an explicit check. The notice explains a denial in plain words without a retry link (a denial is a decision and refreshes on its own) and offers Check again only when the answer could not be read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the caller aborted an import while the task was being created or before multipart completion, the next activity check threw the "Your account or server changed" message, so a deliberate cancel looked like an account switch. An abort within the same account and server now raises an AbortError, whatever the transport reported, while a real context change keeps its own error. The created task is still cancelled by the admitting account, which is why the task request itself is not aborted mid-flight: its ID is needed for that cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A finished upload always gets the "Uploaded successfully" confirmation. A host callback used to replace it, so the empty-account page left the importer open at 100% with no message. Hosts are still notified and can supply their own confirmation text. - The direct /import page no longer locks a denied, unchecked or offline user in an unclosable modal with disabled drop zones: the importer can be closed, and the page offers Import workspace again (same source) or Open AppFlowy. - After a failed upload the retained file shows "Upload failed" and an "Upload again" button, instead of a second "Retry" beside the access check with a different meaning. - A cancelled upload is not reported as a failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Empty-account page (shown when the account has no workspace): - Branded layout titled "You're not in a workspace yet" that shows the signed-in email and how to get invited. - Lists pending invitations (GET /api/workspace/invite?status=Pending, the route Desktop uses) with Join, which needs no creation permission, and any existing workspaces with Open. - The most useful enabled action is primary. A denial is explained as text and hides Create/Import instead of showing disabled buttons with a tiny Retry link; loading and unreadable answers keep them disabled with the reason beside them. - One Check again refreshes workspaces, invitations and creation access, reports a failed refresh, and finishes an unconfirmed creation request. - Sign out uses the app's standard sign-out (clear the saved redirect, /login?force=true), so an earlier session's redirect cannot follow the next sign-in. - A received import says the new workspace is still being processed. - The create dialog opens with the suggested name; a dialog kept mounted while closed no longer resets it to an empty string. Creation dialog, shared by the switcher and the empty-account page: - An unconfirmed request shows "We couldn't confirm that the workspace was created." with Check again (same request ID, never a duplicate) and Discard. A created workspace that failed to open offers Open workspace and Dismiss. An unreadable saved request is explained with Discard instead of silently disabling Create. Workspace switcher menu: - The access notice and its Retry button lived inside the Radix menu, where Tab is suppressed, and disabled items are skipped by arrow keys. Unavailable Create/Import entries are now focusable, aria-disabled menu items whose reason is announced and shown in a tooltip, and Check again is a real menu item shown only when the answer could not be read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useSearchAccessScope treated window focus, tab visibility, online/offline, every WebSocket readyState transition and every token refresh as permission invalidations, so open search results, AI summaries, recent pages and mention suggestions blanked and re-requested on each alt-tab or reconnect (R37), and in-flight Ask AI / create-page attempts were abandoned by them. The hook now separates: - hard invalidation (scope changes, isCurrent() false synchronously): permission and share hints, sign-in/sign-out, and a different session or account, also from another tab. A routine refresh keeps GoTrue's session_id and is no longer a session change; tokens without that claim stay conservative. - isSessionCurrent(): the session part only, so work that already reached the server can finish after a permission hint. - refreshKey: the socket reopened after a disconnect (missed hints), for background revalidation that keeps the current snapshot. - online: browser connectivity for offline states. Only rendering assigns the live scope; invalidation can no longer be undone by a pending effect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Settings suites stubbed workspace creation as always allowed, so removing the gate on Manage Data's Import button would have passed. The new cases mount the panel with a denied, loading and unreadable answer (button disabled, no import, reason shown, Check again only for an unreadable answer) and with an allowed or legacy-server answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Search dialog fixes on top of the scope hook:
- Ask AI (R34): once the chat page is created the attempt runs to the end.
A permission hint re-reads the parent before attaching context (sources
outside the fresh, permission-filtered tree are dropped) and still opens
the chat; closing the dialog shows "AI chat created" with an Open action
instead of orphaning the page; only another session stops silently. The
button stays busy for the attempt's life, so one request cannot create two
chats, and a permission change before creation says "try again".
- Ask AI destination (R43, Search part): the same Write + "who can add pages"
rule as other direct-child creation is checked before creating; denials
(and server 1012/403 refusals) explain in plain words; older servers
without the routes and transient probe failures let the server decide.
- Recent pages (R35, R38): loading, offline, failed (with Try again) and
empty ("No recent pages yet.") are distinct; clearing the query reuses the
list loaded for this dialog opening and scope instead of re-fetching; a
read that settles while a query is typed is kept; a reconnect revalidates
in the background.
- Best matches (R39, R37): offline shows "You're offline" instead of "No
search results", runs the skipped query on reconnect, and keeps loaded
results with Ask AI disabled and explained; a socket reconnect
revalidates every loaded page and the AI summary in the background and
swaps them together, keeping the current response meanwhile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ages - Cache (R40): entries belong to the access scope object instead of a cache instance recreated on every open, so a reopened picker within an unchanged permission/session scope shows its suggestions at once and revalidates them. A permission or session change (also while closed) or a remount still starts empty, and a retired scope frees its slots in the shared map. A transient background-refresh failure keeps the cached list; RetryLater and other failures still drop it. - Row-only RetryLater (R41): people and page results from the parallel request passed their own authority check and stay visible; only the rows wait for the server's cooldown (partial rows read before the update are dropped) with a "Refreshing suggestions..." status instead of a bare skeleton, then the full search runs again. - Create page (R36): the user's own new page is linked even when a permission hint lands during creation, as long as the same picker opening is active and the editor is editable; otherwise a toast offers to open it. A closed editor or another session is never touched. Repeated clicks no longer create duplicates, and failures (denied vs. other) are reported. - Routine events (R37): a reconnect revalidates in the background without clearing, and the highlighted option follows its suggestion by key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page-creation policy (sidebar_edit_policy) was a full-width native
select under a bold question and a three-sentence explanation, styled
unlike every other control in the General tab, with capitalisation that
contradicted the rows above it and a defensive sentence about spaces and
workspaces.
It is now a row in its own card, below the access cards, built like the
other access rows: icon, "Who can add pages to this space", a one-line
description that wraps, and a ghost dropdown with "Only space owners" /
"Anyone who can edit" using the shared Web/Desktop vocabulary. The row
is shared by Create and Manage Space and only reports an actual change,
so opening the menu or picking the current value never sends a request.
A policy from a newer server is shown neutrally ("Other setting") and
kept as received in unrelated saves.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per-person access in a Custom space was a native select stacked under
the Role dropdown with two grey lines ("Space default: ...",
"Assignment access: ...") inside a column headed Role. The select
snapped back to the old value while three requests ran, a failure
reloaded the whole dialog, and success gave no feedback.
- The member table is now Name | Access | Role. Access is a compact
menu like the General tab's: "Space default (Can edit)", "Can view",
"Can comment", "Can edit". The trigger shows what the person or group
gets now, e.g. "Can edit · default". One explanation lives in an info
tooltip on the Access header.
- Owners show "Full access" as plain text. Rows that cannot be changed
say why in a tooltip: people who only have shared pages, access that
comes from the space settings, and rows whose account details did not
load (previously a silently disabled control).
- A choice is shown immediately with a spinner until the server
answers, then confirmed with an "Access updated" toast. A failure
restores the last confirmed value, shows the error, and reconciles
only the list in the background; the dialog is not reloaded. The
result survives the server's permission notification arriving first.
- Copy uses the shared vocabulary without internal terms (override,
inherited membership, assignment, roster). Servers without per-person
access keep the Name | Role table and no longer show a message about
what the server lacks. People who only have shared pages are told
where that access is managed instead of being pointed to General.
- The connected Playwright checks read the menu's data-value instead of
the removed select.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Manage Space switches to UUID routes when the server supports per-person access, but the suite only asserted those mocks were not called. With member_access_overrides_supported, the tests now prove that adding a person goes through addSpaceMemberByUuid with only the role (so they keep the space default and no level is frozen), that a candidate without a UUID is refused instead of falling back to the legacy route, that role changes and removal use the UUID routes without an access level, that a missing member UUID is reported without calling the server, and that group add/promote payloads carry no access level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The component tests stub t(), so nothing checked the real strings behind the Access menu and the page-creation row. This test loads en.json and fr-FR.json through i18next and asserts the shared Web/Desktop vocabulary, the composed "Space default (Can edit)" / "Can edit · default" labels, a French translation with matching placeholders for every string these controls can show, and that none of them contains internal permission terms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Current servers report "Owners and selected members" as owners_only in the two-mode space_creation_policy field, so clients pinned to two modes keep reading workspace settings, and carry the exact policy in exact_space_creation_policy. Send the exact field (plus the two-mode field older servers understand) and confirm the change from the exact field, falling back to the two-mode field on servers that predate it. Otherwise choosing selected creators failed as unconfirmed, and Owners only could be confirmed while selected creators were still active. Validation and confirmation errors now use short translated messages, with English and French copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oads moving - Objects whose last full admission (fresh edit access, no quarantine) still holds send edits to sibling tabs and the server immediately again; other edits persist and are checked first. The permission gate exposes a synchronous peek with a background refresh before expiry. - A transient access-check failure retries with backoff and wakes the socket owner instead of leaving the row idle. - Focus, visibility, online and socket-open only re-check access; a real permission change aborts only uploads whose fresh check is denied, and a check superseded by a permission change is rescheduled. - An explicit retry reports released/denied/unknown objects and wakes the socket owner, which may be another tab; a tab now drains edits released by a retry elsewhere instead of keeping a stale in-memory block. - Rows written before outbox entries recorded their server are adopted by the matching account and workspace and sent only after a fresh check (a denial quarantines them); rows of another server are never replayed. - Full syncs no longer issue one permission probe per item (which could drop items silently); quarantined objects are excluded and logged. - Add discardPermissionBlockedEdits for the paused-edits Discard action. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eeting usable When the member has no private space, "Start transcribing" silently created a Private space with no check of space creation (B). A denied member got a server error and the already reserved Meet window was closed, so they could not even join the meeting. The launcher now reads the space-creation decision first. If it is denied, the meeting opens without a notes page and a message explains why; a server without the capability route, or a failed check, lets the server decide. Any notes-page failure after the meeting is known now opens the meeting in the reserved window instead of closing it, and keeps the reminder so transcription can be retried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Record that a server without the space-creation capability route keeps Create space, Duplicate Space and calendar meeting spaces available and decides itself, how New Page, Duplicate and Move check the destination, and how definitive versus ambiguous creation outcomes are recovered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p dialog Space creation: - Policy is chosen with option cards (no native radios) that save immediately, show the pending choice, confirm with a toast that offers Undo, and keep the confirmed policy on failure. The third card explains when an older server can't choose members. - Selected members and groups appear only while "Owners and selected members" is active; otherwise one muted line counts the saved selections. - Adding uses an inline picker modelled on the People search: avatar, name and email, groups with member counts, one-click Add. Guests, Owners, pending invitations and accounts without a stable ID are never offered. - Rows show avatar + name over email, a "Not eligible" badge with a reason, and an icon Remove with an accessible name; removal toasts offer Undo when a fresh grant would be accepted. Removed entries are hidden and raw IDs are never shown. Every keyset page is read so hidden entries can't cut the list. - Rows and the directory revalidate in the background: permission changes no longer wipe the roster, and failures get their own Retry while mutation errors use toasts. - Guests and Members never request the group directory, so a 403 can't surface as a connection problem; real errors say what failed. Access to all spaces: - Configured per group in People > Groups > group > General (also for directory-managed groups, whose identity stays read-only) with an Off / Can view / Can edit menu that saves immediately with a toast and asks for confirmation when granting or raising access. - Permissions shows a read-only summary of covered groups instead of a second searchable, paginated group list with per-row Save buttons. Copy follows the shared vocabulary in plain words, with French for every new key and for the shared Retry, Can view, Can edit and Loading strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Manage groups" in the Permissions summary now switches Settings to People on the Groups tab, where access to all spaces is configured; choosing a menu item manually still opens People on Members. The menu entry reads "Permissions" (one line in the 228px menu) and uses the app's lock icon instead of a lucide glyph with a different stroke. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Three branch tests mocked runtime-config with a closure over a variable declared later in the file. Main's server restore boundary reads the base URL while modules load, so the mock now falls back to the default until the variable exists. - Main's atomic-creation test mocked the sync outbox without the branch's sign-out hooks: invalidToken now stops uploads (setCurrentSession, clearDrainConfig) instead of purging unsent edits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ShareViewItem (main): the shared Add menu now renders the branch's permission hook and duplicate-destination check, so the suite mocks the app event emitter and keeps the duplicate check (a More-menu action that is mocked out) out of the permission calls it inspects. - usePageOperations.publish (main): an authored initial page still uses the composed refresh path, but Create space now always passes the branch's workspace check as the activation. A new test stops an authored space once its workspace is left while the request is pending. - usePageOperations.addPageRefusal (branch): members creating under Shared with me are no longer refused locally (main #629, the server decides); guests still get the coded denial without a toast, also there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the merge, a missing default first page (a document without authored content) is added through main's atomic default-page creation under the requested ID, while authored first pages keep the page-view request and its returned-ID check. The existing recovery cases now carry authored content so they keep testing the page-view path, and a new case checks that a default first page is created atomically with the saved ID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's getErrorMessage (#618) never shows an Error's message unless the error carries a public user_error envelope, so every plain sentence the permission flows throw ("Your administrator hasn't allowed you to create workspaces.", "We couldn't confirm that the workspace was created.", context changes, first-page problems) turned into generic guidance. PlainError marks translated client copy as public the same way main's ClientValidationError does, and the branch's translated throws (and its SpaceCreationFirstPageError and InvalidSpaceCreationIntentError) use it. Server, library and diagnostic text still never reaches the interface. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
These branch tests asserted the raw message of a mocked failure, which base's getErrorMessage used to show. After the merge the shared renderer shows the catalog's words for known codes (1008, 1012, 1068, HTTP 403/429), generic guidance for unknown client errors, and "Error <code>: ..." for unmapped codes; the tests now expect exactly that. Two Check-again toasts wait for the second call, since the first attempt already showed the same generic text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both sides decide failure messages: the branch with plain copy that hides server text (changeFailureReason, ManageSpace's read toasts, New page), main with public server or catalog messages (#618). The merged order is: billing remedies; the branch's words for lost rights and things that changed elsewhere (and, in New page, a refused destination); then a public message from the server or the shared catalog; then the copy an error raised by this client carries; then the screen's own fallback. Server text is never shown. New page also reads a refusal through main's atomic creation, which keeps the server's answer as the cause of its pending command, and shows the reviewed pending guidance when a creation could not be confirmed. Tests: change-failure precedence cases; two branch tests now expect the catalog's words for an HTTP 500 ("AppFlowy isn't available right now. Try again later.") instead of the generic reason; New page covers an atomic refusal and an unconfirmed atomic creation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's journal kept every failed page creation pending, so a page the server refused for lack of permission stayed in the pending list with only a retry that could never succeed, next to the branch's plain refusal. A definitive refusal of a never-committed default page (first try or explicit retry) now releases the saved command and answers with the refusal itself, matching the branch's rule that definitive failures release drafts. Ambiguous outcomes and committed receipts stay pending for an explicit retry, and the pending list reports a released refusal instead of calling it pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved Search to the shared getErrorMessage with an import at the top of the file, while the branch already imported getAPIErrorCode from the same module, so the merged file failed import/no-duplicates. Use one import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds of the permissions PR (#621, 3054415) created Dexie version 13 with sync_permission_blocks, while main released its own version 13 with atomic_creation. The merge kept main's version 13 and declared the denial table from version 14 on. Dexie runs every version from the stored one up and deletes the tables a step doesn't declare, so a PR-build database lost every durable denial at the version 13 step and got an empty table back at 14. Version 13 now declares both tables. Main's version 13 databases gain the denial table at that step and PR-build databases gain atomic_creation; both keep their rows. The upgrade test covers the PR-build path, including the version 15 cache cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reens After the merge, changeFailureReason and Manage Space's read toasts still returned Error.message for any client error without an API code. Since PlainError and ClientValidationError already reach the interface through the shared renderer (publicErrorMessage), that step only showed text nobody reviewed: a guard's "A valid member UUID is required" or a TypeError's "Cannot read properties of undefined". Main's #618 rejects exactly that. Both helpers now end with the public message or their own fallback. Tests use PlainError where the real services throw it (stale dialogs and stale capabilities) and expect generic copy for raw errors, as main's Manage Space test did for a failed type change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merged permission screens prefer the shared error catalog's words over their own translated fallbacks, but main's userError, userErrorDetail and atomicCreation keys had no French. Without a French server catalog, a French reader saw "AppFlowy isn't available right now. Try again later." where the branch used to say "Une erreur s'est produite. Réessayez." userError now carries the Cloud catalog's reviewed French (libs/app-error/resources/locales/fr.json, #1369), whose English matches the Web catalog exactly, so Web and server say the same thing. The two detail strings and the atomic creation copy get natural French. A test checks that a French reader gets French for a server failure on a permission screen, and that every key in these namespaces has French with the same placeholders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's #630 scopes every IndexedDB name, lock, channel and localStorage key to the server restore generation, and #626 keeps atomic page commands in the generation-scoped database. The branch's space and workspace creation drafts live in sessionStorage, which survives the reload a restore triggers, and their keys carried no generation. After a restore the previous generation's draft was offered for Resume against the restored server, where its first page found no saved atomic command and would get a new request ID. Both draft keys now go through serverRestore.cacheKey, so the initial generation keeps its existing keys and a restored tab starts clean. Tests cover a draft saved before a restore: it is not offered, a new request is allowed, and the earlier draft is left untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…moval "Also remove page copies from this browser" is meant for shared computers, but after a server restore the merged cleanup only touched the current restore generation. The account's copies from before the restore (per-object databases, rows in the old shared cache, checkpoint keys) stayed on the device, although nothing ever reads or uploads them again. Sign-out cleanup now visits every restore generation found by listing IndexedDB and localStorage, with the same rules in each: the account's copies and pre-scope copies go, objects with unsent edits in that generation's own outbox stay, and nothing is removed from a generation whose outbox cannot be read. An earlier generation's shared cache is opened as stored, never upgraded. The outbox lookup bounds name maxKey once per key, which spec-conforming IndexedDB requires (the old bounds failed under fake-indexeddb). Tests cover cleanup under a non-initial generation with real IndexedDB, an unreadable earlier outbox, a sign-in during cleanup, the generation key helpers, and the broadcast channel and leader lock names of a restored tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge relaxed main's per-login check to a same-account check for ordinary requests (682be4a) and moved the storage-event generation bump into token-storage.ts, but the guards themselves had no test: removing the request's account check, the stale refresh-token pre-check or the storage listener left every suite green. New cases: a request is refused (and nobody is signed out) when another account signs in right after this tab stored its refreshed token; refreshing a token that was already replaced fails as a changed session without asking GoTrue, so no caller ends the current login; and a storage event invalidates captured work even when another tab switches away and back to the identical token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0dd193e guards the missing selected workspace in Shared with me, but every test provided one, so throwing on it left the suite green. A shared row of an account that has no selected workspace now renders and offers neither Add nor Move to trash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opying Sign-out cleanup listed every IndexedDB name and rebuilt its generation's array for each one, which is quadratic on devices with one database per document. Names are now appended in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Still images cannot show a flow: a save confirmed by the server, a refusal after a race, an edit that survives a reload. permission-videos.capture.spec.ts records five flows as real Playwright tests on the gallery's synthetic Demo workspace (same seed, stack and network guard), each with its assertions, and is opt-in behind APPFLOWY_PERMISSION_VIDEOS=1 so ordinary runs skip it: - groups-two-levels: the HR group can edit HR and the Users group can view it; the owner switches Users to Can comment and back from the UI - view-group-reads-only and edit-group-edits: through those groups a member can only read, or edits and the server keeps the edit - workspace-creation-control: who may create workspaces - space-creation-control: the owner narrows who can create spaces Each test records one window per person and writes a timeline of its captions and passed checks. The caption banner and the pointer dot are drawn by the harness (headless video has no cursor) and documented as such. permission-videos-encode.py joins a test's windows in the order they were on screen and encodes the MP4 and GIF stored in the evidence folder's videos/. The gallery spec and the videos now share one browser-session helper, so their isolation is the same, and the gallery gains a capture of HR's two group levels with nothing open. The seed's removal of a person's own space membership never matched on this server: it looked for the source "direct", which the server does not send. It now uses the sources the Web client treats as a person's own row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration branch merged main, so the screenshot gallery ran again on b59969d against server 805c9bab0, twice, each time on a freshly seeded stack, to show what the merge changed on these screens. Seven of the eight capture tests passed and their screens did not change: 14 images came out byte for byte the same, sign-out-remove-data.png differs only in the date it shows and was retaken, and permissions-member-read-only.png (2 pixels off by 1 of 255) was kept. A capture of HR's two group levels with nothing open was added: the HR group has Can edit, the Users group Can view. The owner's Permissions test failed both times. The merged client adds server_restore_id to every Cloud request and the server's space creators list rejects unknown query fields with 400, so the selected members and groups never load. Its six images were not retaken and are marked as showing the panel before the merge. The README also describes a second defect seen in the videos (a reader is told about waiting changes he never made), adds a Videos section for the five recordings, and the manifest records the runs, hashes, sizes, durations and the server state read back afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…traffic edit-group-edits had no finally, so a failure after Sam typed left his sentence in Onboarding checklist: the next run failed its first check and gallery captures of that page showed the stray line until the stack was purged. The test now runs in try/finally. After a failure it closes its windows and the owner puts the seeded line back through Cloud's web-update API (restorePageText deletes the extra blocks the way the app does), then reads the page back. Its server checks compare the page's lines, so a half typed or empty leftover line is caught as well as the whole sentence. view-group-reads-only proves read-only access partly by absence (no title input, no Add icon or cover buttons, no block handles). edit-group-edits now finds those controls with the same locators and hovers, as their positive control. view-group-reads-only also moves the pointer dot to the margin after the click, so the line stays readable while Alex types. The network guard only covered the app's HTTP requests: Chromium's own background traffic reached Google, and WebSockets were not filtered (the realtime relay connected any URL). Chromium now starts with its proxy at the closed port 127.0.0.1:9 (it never proxies loopback, so the stack is reached directly), WebSockets outside the stack are closed, and the relay only takes the Cloud socket. Both WebSocket rules are URL patterns rather than functions, so the other sockets keep connecting directly instead of being relayed through the test process. The seed also refuses ports 4001 and 4002. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An independent review of the videos found that the editing video left Sam's sentence behind when it failed, that the README overstated the network guard and claimed every change was undone (a page's edit history is not), that the reader video's absence checks had no positive control, and that its pointer dot covered the line while Alex typed. The harness commit before this one fixes those; this one records the result. The reader and editor videos were recorded again with that harness, in two consecutive runs of all five tests on a freshly reset stack (5 of 5 each); the files are from the second run, so the reader still shows the waiting-changes notice that follows an editing run. The other three videos show nothing the fixes change and are still the files of run D. The screenshot spec ran once more as a validation, with its images kept out of the worktree: 7 of 8 passed as in the refresh, and 16 of its 17 images came out byte for byte the same; the other differs in 2 pixels by 1 of 255, so no image was replaced. Socket sampling during these runs saw only loopback peers. The README and the manifest now say which guard each run had, add a Review fixes section, and record the new runs, hashes, durations and the server state read back afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A view-only reader who opened a page with deletions in its history saw "Changes to 1 page are waiting for edit access" without editing anything. The reply to the server's state vector is built with Y.encodeStateAsUpdate, which, like yrs encode_diff, always writes the complete delete set. For any page with a deletion the reply was never the empty update the protocol already skips. With permission checks every reply goes through the outbox, so a reader got a quarantined 'manifest' row and the notice, and an editor re-sent the whole delete set on every open, reconnect and sibling-tab sync request. A state vector cannot show which deletions the server holds, but the server's own bytes can: it answers a state-vector request with an Update (its diff, written with its complete delete set) before sending its own state vector, and only server Updates carry a message id. Record the deletions of those Updates per Y.Doc, dropped when the collab version or restore generation changes, and treat a reply as a no-op only when it holds no struct and every deletion in it came from the server. A local deletion or insertion is never covered, so it is still kept: blocked for a reader, sent for an editor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the reader-notice fix found three ways a view-only reader can still see "waiting for edit access" without editing: - A reply to a request that is behind the server repeats structs the server holds: a sibling tab that missed a relayed update, or a re-sync whose Manifest a live update overtook. Only structless replies were checked. - A sibling tab's request that arrives before this tab's first server answer was judged against an empty record. - A false row already queued, also by the earlier build, stayed until Discard changes, because waiting rows are never replaced. Recording was also costly on heavily edited pages: every live deletion re-sorted and copied the client's whole range list. The record now keeps the struct ranges as well as the deletions of every server Update (one with a stream message id). It reads them in a single pass over the lib0 v1 bytes, leaves out the gaps that Skip structs mark, and merges them in place. A reply is a no-op only when the server has proven every struct and every deletion in it. The record restarts at each state-vector request this context sends, so a server that lost data since an earlier exchange gets it again in the next reply. While that request is unanswered, a request that lists clients cannot be the server's answer, which starts with an Update. Its reply would reach only the server, and the reply to the Manifest that follows covers it, so it is not queued. When the record grows, queued manifest replies of the same collab generation that it shows to be redundant are deleted, waiting ones included. They hold nothing the server lacks, so no edit is lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening a database view that still has a legacy view-keyed cache queued Y.encodeStateAsUpdate(legacy, stateVector(canonical)) whenever it was longer than two bytes. That diff always carries the legacy copy's complete delete set, so a cache with any deletion queued a no-op on every open. A reader got a waiting row and the "waiting for edit access" notice, and an editor uploaded the same deletions again. Compare the canonical document's snapshot and pending state before and after the merge, and queue the bytes only when something changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The last recording captioned two defects instead of failing: the owner's Permissions panel could not list the selected members and groups (the server refused the server_restore_id query parameter, fixed in server 65bc42360), and a reader was told that changes to a page were waiting for edit access when he had made none (fixed by the three cherry-picked Web commits). Both are fixed, so a caption would now only hide a regression. space-creation-control now asserts that Olivia's panel lists Alex and the Super_Users group and shows no load error. view-group-reads-only watches the reader's window from the moment it opens: a MutationObserver records any appearance of the pending-edits notice (it is in the DOM only while shown), so a notice that comes and goes between two checks still fails the test. It is checked after the read-only checks and again at the end, after the last hold, so the whole time on screen is covered. The gallery spec needs no change: its owner test already asserted the selected rows, which is why it failed against the old server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The last recording showed two defects of the merged code, captioned on screen: the owner's Permissions panel could not list the selected members and groups, and a reader was told that changes were waiting for edit access when he had made none. Both are fixed (server 65bc42360; Web 541acb82, dbd74da9 and d13d06e0, cherry-picked here), so the media should show the fixed behaviour and no defect captions. All five videos were recorded again with the spec that now asserts both fixes: runs H and I passed 5 of 5 each, back to back on the same database after a fresh seed, so in run I the reader opened a page whose history has the editing test's deletions and saw no notice. The files are run I's, encoded with the committed encoder; nothing was cut beyond the documented window joins, sped up or retouched, and the captions remain harness overlays. Only loopback peers were seen in any run. The screenshot spec passed 8 of 8 on a freshly seeded stack (run G3), including the owner's Permissions test that failed before. Of the 23 current images, 19 came out byte for byte the same and 4 differ only by anti-aliasing noise (at most 90 pixels, by at most 8 of 255), so no image was replaced; the six images of the owner's test are now verified on this code. The README and manifest replace the defect sections and the "not retaken" notes with one "Found while recording, fixed" note, and record the new server binary (65bc42360, sha256 a491ff45...cac7), the Web commit the media were recorded at, and each video's duration, size and hashes, all written by a script from the run outputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since dbd74da9, a context that had sent its state-vector request dropped every request that listed clients until a server Update arrived, on the grounds that it came from a sibling tab and the reply to the server's answer would cover it. That answer does not always come. The server answers for a collab it does not hold with a bare request and no Update, and an answer can be lost: the tab that owns the socket closes before relaying it, or the server's read fails and it sends nothing. The server's repair request for a refused edit, also a bare request that lists clients, was then dropped for the rest of the exchange. The drain deletes an edit's row once it is sent, so the edit reached the server only at the next re-sync, and the editor was not told. A sibling tab's request cannot be told apart from a repair request, so both are answered again. A reply queued for a sibling before the server answers is deleted by the existing cleanup once server Updates prove its content; a reader can see the notice until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d13d06e0 queues a legacy view-cache merge only when it changed the canonical copy, comparing its snapshot and pending state before and after. Yjs keeps the store's pendingStructs object when more structs have to wait and replaces only its update bytes, so a legacy cache that added structs the canonical copy cannot integrate yet was judged unchanged and its bytes were not queued. Compare the pending update bytes instead. A local legacy edit always integrates and changes the snapshot, so no edit was skipped before; the merge now queues whenever it changed anything, as intended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The record that lets a reader skip no-op manifest replies, and lets the cleanup delete queued ones, takes any Update with a message id as the server's own. Nothing but server bytes carries one today: local fan-out and sends are built by buildUpdateMessage, and an HTTP sync result carries the server's message id on the server's diff. A later change that put a message id on local bytes would make a tab skip or delete a manifest reply that holds them. Say so at both places, and test that fan-out, sends and manifest replies never carry one. Also record an accepted risk: a server Update sent before the latest state-vector request can be applied after it (a Database message waiting in the per-object queue, a replay after a reset, a late HTTP result or relay) and is recorded like any other. Only if the server lost that data in between does the exchange leave it out, and the next exchange sends it again; the local document keeps it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The walker's struct lengths for strings are the decoded string lengths, as in Yjs. Yjs writes a string that starts inside a surrogate pair whenever a diff starts inside a string item, but the walker suites run under jsdom, where lib0 falls back to a polyfill that throws on a lone surrogate, so that case was never exercised. A browser has native text codecs and never throws. Run a small suite in the Node environment: every diff of a page with emoji, a ZWJ sequence, a byte order mark, lone surrogates and items split inside a pair, from every clock, reads as the clock ranges and delete set Yjs holds, and a server diff that starts inside a pair proves its own clocks and no earlier one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related PRs
Oodrive permissions (plan AppFlowy-IO/AppFlowy-Cloud-Premium#1367):
Description
Web follows the workspace, space and page permissions the server enforces (server #1370, plan #1367; Desktop #1466 uses the same words). All strings exist in English and French.
POST /api/workspacewithout a receipt.204 commits over the first draft (
30544159), including the merge of Web main up to05604b32(#618, #619, #620, #623, #626, #628, #629, #630): 248 review findings across the three repositories were fixed in waves with independent reviews (103 on Web: 98 in round 1, 5 in round 1b), then a gallery design review and the Desktop-parity follow-ups.Found while recording the videos, both fixed:
8468aff5,0c8da2e4,86d828cd). Editors stop re-sending those deletions on every open.65bc42360).Validation
b59969d6, after the merge of main..eslintignore.web) atb59969d6and on every file changed since, plustsc --noEmit --project tsconfig.web.jsonon this head's app source: exit 0;git diff --checkclean.@demo.testdata; Chromium 145 headless), with a server built from #1370's head65bc42360:Known limits
role("Owner" | "Member" | "Guest") on each pending invitation ofGET /api/workspace/invite?status=Pending; older servers omit it and the line is simply not shown.src/vite-env.d.ts:42is excluded by.eslintignore.web.Gallery and provenance · Capture manifest · First-draft validation record · Compatibility guide
Videos
Five Playwright tests recorded on the same stack. Every step is checked on screen and on the server, and each test puts back what it changes. Captions and the pointer dot are test overlays. The videos play in real time, with no speed-up. Click a preview for the MP4.
1. Two groups, two levels in HR. The HR group can edit and the Users group can view. Olivia changes Users to Can comment and back, and the server confirms each change.
2. A view group reads but can't edit. Alex reaches HR only through the Users group (Can view). The page opens read-only, typing changes nothing, and no "waiting for edit access" notice appears.
3. An edit group edits, and the edit is kept. Sam reaches HR only through the HR group (Can edit). He adds a sentence, the server keeps it across a reload, and he deletes it again.
4. Who may create workspaces. Sam sees Create and Import workspace unavailable, with the reason. Jordan, who has no workspace, sees the reason and his guest invitation. Olivia, the one selected creator, creates a workspace, which the test deletes afterwards.
5. Who may create spaces. Olivia's panel lists the selected Alex and Super_Users, and she narrows space creation to Owners only. Alex's Create is then refused with Close, and New page shows Create space as unavailable. The test held back Alex's live update so that the refusal at Create is visible.
Feature Preview
Real Web app against a real server on an isolated stack with synthetic data; the gallery README says what each image does not prove.
Permissions, as the owner: Alex and the Super_Users group selected; the Access to all spaces summary follows below.
Narrowing asks first (cancelled; the policy stayed unchanged).
People > Groups > HR > General: the Access to all spaces menu, and the question before giving HR Can edit (cancelled).
Manage space · HR > Members: Olivia (space owner) shows Full access; Alex keeps the space default.
Two groups, two access levels (Manage space · HR > Members): the HR group has Can edit and the Users group has Can view. Every person and group row has its own Access menu (Space default, Can view, Can comment, Can edit) in Custom spaces; someone in both groups gets the higher level, and space owners always have Full access.
Manage space · HR > General: "Who can add pages to this space: Only space owners".
Create space refused after the owner narrowed the rule: summary, answer and Close; no Marketing space existed afterwards.
Workspace menu without permission to create workspaces.
Signed in without a workspace: a pending guest invitation ("Invited as a guest") with Join, the reason creation is unavailable, Check again and Sign out.
An open page loses access when its group's access is removed (
can_read: false, no editor, no Share); the next capture shows it recovering without a reload.Checklist
🤖 Generated with Claude Code