The palette's local (MiniSearch) path indexes title/ref/tags/fields but
not the rich-text body, so a query that only matches an item's content
showed nothing unless the user knew the hover-only `body:` tooltip. On
sparse local hits, fire a debounced (250ms), current-workspace-scoped
server FTS query and render the extra, deduped hits under a "Matches in
content" section. Request-id token drops stale responses; the section
(and its `.results` container) collapses to the honest "No results"
state when it resolves empty. In all-workspaces mode the section is
labeled "current workspace" since the fetch is a single scoped request.
Also replaces the misleading empty-state tip ("...or any text") with an
honest, always-visible `body:` hint (the old hint was a hover tooltip,
invisible on mobile).
The Danger Zone delete copy (TASK-1976) only mentioned the post-delete
Undo prompt because the persistent recovery surfaces weren't merged yet.
Both are now live: the workspace switcher's "Recently deleted" section
and the /console/deleted-workspaces page. Name all three 30-day recovery
paths so users know they can restore beyond the Undo toast.
Copy-only; no logic change. Keeps the Delete naming, typed-slug confirm,
and owner-only gating.
Closes TASK-1977
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
After deleting a workspace, the settings page now shows the success
toast with an inline Undo action. Undo calls api.workspaces.restore on
the deleted slug (captured before the redirect so it works post-
navigation) and navigates back into the restored workspace. The toast
uses a longer duration and the global toast store survives the
post-delete redirect to /console.
The Danger Zone delete copy now states the workspace stays recoverable
for the full 30-day window (with the Undo prompt right after deleting),
making the soft-delete window honest. Copy intentionally avoids naming
the persistent recovery surfaces (switcher restore = TASK-1974, console
Deleted-workspaces page = TASK-1975) since those land in sibling tasks;
those PRs can add the "restore from the switcher" pointer once merged.
Typed-slug confirm + owner-only gating (TASK-1967) unchanged.
Closes TASK-1976
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* feat(web): add "Recently deleted" section to workspace switcher
Adds a collapsible "Recently deleted" section below the active-workspaces
list in WorkspaceSwitcher, populated from api.workspaces.listDeleted() and
loaded each time the switcher opens. Each row shows the workspace name, a
subtle "N days left", and an inline Restore button that calls
api.workspaces.restore() then refreshes both the deleted list and the
active workspaces list so the restored workspace reappears; a success
toast confirms. The whole section is hidden when there are no deleted
workspaces, and fetch failures are swallowed quietly so the switcher never
breaks. Renders in both the desktop dropdown and the mobile BottomSheet.
Closes TASK-1974
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(web): decouple restore success toast from list refresh
A failing workspaceStore.loadAll() after a successful restore no longer
shows a misleading "Couldn't restore" toast. The restore API call now has
its own catch; the post-restore refresh is guarded separately so a reload
failure stays silent (the restore already succeeded). Addresses Codex P2.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(web): sequence deleted-list fetches to avoid stale overwrite
Adds a monotonic request token to loadDeleted() (mirroring workspaceStore's
membershipSeq) so an older open-triggered listDeleted() response can no
longer clobber the fresher post-restore refresh and re-surface a
just-restored workspace with a live Restore button. Addresses Codex P2.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Add a full-page table of the caller's soft-deleted workspaces
(name, deleted date, days-left, Restore per row) backed by
api.workspaces.listDeleted / restore. Restore removes the row and
toasts success; loading/error/empty states included. Wire a
"Deleted workspaces" link into the console layout nav.
Closes TASK-1975
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Wire the web TypeScript API client to the TASK-1970 restore endpoints:
- workspaces.restore(slug) -> POST /workspaces/{slug}/restore, returns
the restored Workspace.
- workspaces.listDeleted() -> GET /workspaces/deleted, returns
DeletedWorkspace[] (Workspace + purge_at + days_left).
Uses the shared request() helper and the existing DeletedWorkspace type
(no redefinition). The restore UI that consumes these lands in
TASK-1974/1975/1976.
Closes TASK-1971
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Foundation for PLAN-1969 (user-recoverable workspace soft-delete). A
workspace delete only stamps workspaces.deleted_at; items/collections/
members are untouched, hidden transitively. Restore clears deleted_at so
everything re-surfaces intact.
Store (internal/store/workspaces.go):
- RestoreWorkspace(slug): UPDATE ... SET deleted_at = NULL WHERE slug=?
AND deleted_at IS NOT NULL. Returns sql.ErrNoRows (-> 404) when no
soft-deleted row matched (already live or purged).
- ListDeletedWorkspaces(userID, cutoff): owner-scoped, deleted_at within
the window, ordered deleted_at DESC. Account-deleted workspaces have no
live owner, so they never leak.
- GetDeletedWorkspaceBySlug(slug): resolves a soft-deleted row (the normal
resolvers filter deleted_at IS NULL) so the handler can tell 403 from 404.
- Dual-dialect via s.q/s.dialect; no migration (deleted_at already exists).
Handlers (internal/server/handlers_workspaces.go):
- POST /api/v1/workspaces/{slug}/restore: owner-only; 404 not-restorable,
403 non-owner, 200 + restored workspace; logs a "restored" activity.
- GET /api/v1/workspaces/deleted: owner-scoped list with per-entry
purge_at + days_left, both derived from workspacePurgeRetention so
restore and the purge sweeper share ONE 30-day window (no drift).
- Both routed outside the /{slug} RequireWorkspaceAccess subrouter (which
resolves only live workspaces); restore enforces owner authz inline.
CLI client (internal/cli/client.go): RestoreWorkspace + ListDeletedWorkspaces.
TS type (web/src/lib/types/index.ts): Workspace.deleted_at + DeletedWorkspace.
Tests: store (resurface-intact; double-restore/live -> ErrNoRows; window
boundary 29d IN / 31d OUT + owner-scoping) and handler (owner-only 403,
404 live/unknown, 200 restore, owner-scoped deleted-list). Green on
SQLite and Postgres (make test-pg); golangci-lint clean.
Closes TASK-1970
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
The workspace Danger Zone said "Archive" and "The data is preserved but
no longer accessible." That is false: the action soft-deletes
(workspaces.deleted_at) and TASK-1966's sweeper hard-purges any
deleted_at workspace after 30 days.
Rename the action to "Delete" across the heading, buttons, in-flight
label, and confirm warning; rewrite the body copy to state it hides the
workspace + all contents immediately and permanently deletes it 30 days
later; update the success/failure toasts. Typed-slug confirm + owner-only
gating unchanged; copy/toasts only.
A workspace restore path (within the 30-day window) is planned as a
follow-up, so the copy states the 30-day finality without claiming the
workspace is unrecoverable.
Closes TASK-1967. Follows TASK-1966 (the purge sweeper).
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Adds a two-step delete-account flow to the console settings Danger Zone,
cloning the inline TOTP-disable reveal pattern. Branches on auth method:
password re-entry for email/password + self-host accounts, typed
confirmation (email or DELETE) for cloud OAuth-only accounts, plus a
required TOTP code when 2FA is enabled (server re-verifies).
Shows the three account-deletion warnings (irreversible; cancels paid
subscription with no refund; shared workspaces are deleted and members
lose access), nudges users to export their data first, and renders the
server's billing_cancel_failed / partial_delete messages verbatim. On
success it clears the auth store and hard-redirects to /login with no
further API calls. Focus-on-reveal, Escape-to-cancel, aria-describedby.
Closes TASK-1962
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Add a new ungated Danger Zone card as the last section of the account
settings page, with an "Export my data" button wired to
exportAndDownloadAccountData(). Export works self-host too, so the
section is not gated on cloudMode.
The button holds a persistent disabled + in-flight ("Exporting...")
state for the whole request (server has a 60s deadline) so the user is
never left without feedback. Inline error rendering (.error) surfaces
the restricted-owner 403 (PadApiError.message) and network/timeout
failures; a .success line confirms the download.
Reuses .danger-btn/.error/.success; the section header + border are red
per the existing danger palette (#ef4444). Follows the page's
per-section $state trio convention (exportSaving/exportMsg/exportError).
Closes TASK-1961
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
The delete-account UI must branch between a password prompt (self-host or
any user with a password) and a confirm-only flow (OAuth-only users with
no password). The client had no signal for this: oauth_providers is not a
valid proxy since a user can have both a password and linked OAuth.
Add "password_set": user.HasPassword() to the /auth/me response map and
password_set?: boolean to the TS User interface. A handler test asserts
the field for both a password user (true, via bootstrap) and an
OAuth-only user (false, via CreateOAuthUser).
Closes TASK-1957.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Wire the existing account delete/export endpoints into the web API
client (TASK-1960). Neither was callable from the web today.
- api.auth.deleteAccount(opts) — POSTs /auth/delete-account through the
shared request() helper so X-CSRF-Token + credentials are attached
(a hand-rolled fetch would 403). Optional password/confirm/totp_code.
- api.auth.exportAccountData() — modeled on exportItemArtifact: bare
credentialed fetch, 401 -> /login, non-OK -> PadApiError (surfaces the
restricted-owner 403, BUG-1945), Content-Disposition filename with a
pad-account-export.json fallback. Returns { filename, text }.
- exportAndDownloadAccountData() util pipes the bytes into
downloadTextFile as application/json.
Closes TASK-1960
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Reorder ConnectWorkspaceModal so MCP setup is the default and first tab —
most users landing here have never connected an agent, so the OAuth "fresh
agent" path is their real first step. CLI is second; the claim code moves to
a third "Connect code" tab, reframed as a scoped-grant add-on rather than the
(misleading) "recommended" default it was.
Close the zero-grants dead end: add Store.HasActiveConnectionForUser and
surface has_any_connection on the claim-code endpoint, so a user who opens the
Connect-code tab with no connected agent gets steered to set one up first
instead of a live-looking but unredeemable code. Hide the MCP + code tabs on
self-host deployments without a public MCP URL (both depend on the remote
OAuth server), leaving CLI as the sole, default path there.
Verified: go build ./..., go test ./internal/server/ ./internal/store/,
web npm run check (0 errors), and a Codex review all pass clean.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(web): dedupe collab flush in editor-markdown space (BUG-1941)
A no-edit view's flush re-serialized the editor markdown through the
flush-time wiki-link index before comparing to the loaded baseline;
when that index differed from the seed-time index (or the stored link
was already non-canonical), the dedupe missed and a spurious PATCH
bumped updated_at, floating the card in Manual-sorted boards
(regression of BUG-1899). Capture the exact markdown seeded into the
Y.Doc and short-circuit the flush in editor-markdown space, before
markdownToWikiLinks ever runs, scoped to sessions with no prior flush
so revert-after-edit still saves. Also give the Manual sort comparator
a deterministic tiebreak so a stray updated_at bump can't reorder
untouched cards.
* fix(web): eagerly compute collab-flush seed for every tab (BUG-1941 follow-up)
Only the multi-tab-election winner captured a seedMd via the lazy-seed
effect, leaving a second tab on the same item — or simply reopening an
item that already has collab history — without editor-space dedupe
coverage, falling back to the pre-fix spurious-PATCH behavior. Compute
a best-effort seedMd for every tab at context-creation time by
projecting the same item.content baseline the storage-space dedupe
already trusts through the identical wiki-link transform; a mismatch
here can only cause a missed dedupe (falls through to the existing
compare), never a false one, so it's safe even when the projection
goes stale. The winning tab's precise lazy-seed capture still
overwrites this value unchanged.
Cloud self-registration mails a link to /verify-email/<token> but no
SvelteKit route existed there, so every self-registered user 404'd and
could never verify (blocking mutate/invite under DR-1 model b). Add the
route to auto-consume the token on mount, plus a client method for the
existing POST /auth/verify-email consume endpoint (distinct from the
admin force-verify). Success refreshes the session so emailVerified
flips and redirects to /console; failure offers resend (or sign-in).
Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
Wave 5 of PLAN-1933 (DR-1 model b) — surfaces the unverified-email state
in the web UI and makes it actionable.
- AuthSession user type gains `email_verified` (owns the session user-type
change); register response user type gains it too.
- authStore.emailVerified getter, default TRUE (mirrors `emailConfigured ??
true`) — a missing field or a self-host instance must never show the
banner.
- VerifyEmailBanner rendered in the workspace layout above ConnectBanner,
shown only when `cloudMode && user && !emailVerified`, with a Resend
button hitting POST /auth/resend-verification and a "sent" confirmation
(enumeration-safe, always 200).
- api.auth.resendVerification client method.
- /register shows a "check your email to verify your account" state after a
cloud self-serve signup returns an unverified user, instead of navigating
in and implying full access; includes resend + continue actions.
Gates: make check (lint + go test + govulncheck + web-check) green;
cd web && npm run check → 0 errors.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Wave 4 of PLAN-1933 (DR-7). Adds a web-console-only admin override to
force-verify a locked-out unverified account. No CLI, no MCP (matches
the no-auth-mutation-on-MCP rule).
Server:
- POST /api/v1/admin/users/{userID}/verify-email — admin-only, mirrors
handleAdminEnableUser. Reuses the existing SetUserEmailVerified store
method (added in Wave 3b) and audits with the distinct
ActionEmailVerifiedByAdmin action (separate from the self-serve
ActionEmailVerified — a force-verify is an operator security action).
Idempotent (already-verified returns 200 no-op).
- Surface email_verified_at in the admin list + get-user JSON so the
console knows verified state (Wave 1 only added the store-level scan).
Web:
- adminVerifyEmail client method (api.admin.verifyEmail) confined to the
admin section of client.ts.
- "Mark email verified" action in the admin user panel (UserSettingsForm),
shown only when the target user is unverified.
- email_verified_at added to the AdminUser type.
Tests: admin force-verifies an unverified user (flips email_verified_at +
audits ActionEmailVerifiedByAdmin, not the self-serve action) and the
now-verified session is unblocked; non-admin -> 403 with no side-effect.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
On Pad Cloud, an invitee who signed up via Google/GitHub from /join was
never added to the inviting workspace: /join rendered no OAuth buttons,
and the OAuth round trip dropped the pending invitation.
Surface the shared AuthOAuthButtons on /join (cloud-mode only, matching
/login and /register) and thread the invite code through the provider
link's ?redirect= as /join/<code>. OAuth returns via a full-page nav to
that URL, where onMount's existing session probe sees `authenticated`
and calls acceptInvitation(code) to finish the join. The redirect stays
same-origin (validateRedirect); the accept remains email-bound server
-side (403 invitation_email_mismatch on a mismatched OAuth email), whose
message already surfaces in the page's error state.
Single-repo per PLAN-1933 DR-8 (option A) — no pad-cloud change.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
On Pad Cloud the /join/[code] page never showed the invited email, so a
mistyped address hit a confusing 403 invitation_email_mismatch. Add a
non-consuming, public, always-200, rate-limited preview endpoint and wire
the join page to prefill the invited email read-only.
- GET /api/v1/invitations/{code}/preview returns {found,email,workspace_name,
has_account}. Reuses store.GetInvitationByCode (never accepts/consumes the
invite). Invalid/expired/missing codes and dangling-workspace codes all
return 200 {found:false} — no 404 status signal (enumeration safety). A
genuine DB fault still 500s (code-independent, leaks nothing).
- Public/pre-auth: added to isPublicAPIPath (matches only the trailing
/preview segment, so /accept stays auth-gated).
- Dedicated per-IP rate limiter (20/min, burst 20) wired into the RateLimit
switch so the endpoint can't be used to enumerate invite codes.
- TS client: api.members.previewInvitation + InvitationPreview type.
- /join page calls preview on mount, prefills + locks the invited email, and
defaults register-vs-login by has_account. Keeps the mode-switch affordance
and BUG-1930's register default when preview is unavailable.
- Tests: non-consumption, has_account, always-200 on unknown code, rate limit.
Composes with BUG-1930 (register default). Wave 0 of PLAN-1933 / IDEA-1927 §B5.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
A never-registered invitee previously saw the auth form defaulted to
login, tried to sign into a nonexistent account, and dead-ended. The
register path already exists and auto-accepts the invitation via the
invitation code, but was hidden behind a "Create one" toggle. Default
to register instead; the "already have an account? sign in" switch
still works both directions.
* fix(web): don't mask/redirect on 401 from auth-form submissions (BUG-1929)
The client's global 401 interceptor hard-redirected to /login and
threw a hardcoded "Authentication required" message for every 401,
including bad-credentials responses from login, register, and 2FA
verification — masking the server's real error and stranding invitees
mid-join. Auth-form 401s now surface the server's message inline
instead; session-expiry 401s elsewhere are unchanged except that the
redirect now preserves a ?redirect= return-to path.
* test(web): pin hostile ?redirect= values as rejected (BUG-1929 follow-up)
Codex R2 found no live browser-exploitable bypass in validateRedirect,
but two of the five hostile forms it checked (percent-encoded slash/
backslash, e.g. /%2Fhost) passed through unrejected today — not
exploitable via normal navigation, but a validator gap nonetheless.
Reject percent-encoded slash/backslash defensively and add unit pins
for all five hostile shapes so a future regression here can't be silent.
* feat(server): add REST endpoints for project next/standup/changelog
Adds GET /workspaces/{ws}/next, /standup, /changelog — session-authed
reads mirroring `pad project next|standup|changelog --format json`,
reusing buildDashboardResponse + store.ListItems so the browser
WebMCP surface stops returning "not available" for these catalog
actions (TASK-1894). Cross-references the MCP HTTP transport's
existing dispatchProjectNext/Standup/Changelog (dispatch_http_slice4.go)
with KEEP IN SYNC comments at both sites, since this is now a third
reproduction of the same reshaping contract pending a follow-up
consolidation.
* feat(web): wire next/standup/changelog into WebMCP dispatch + api client
Adds client.ts next()/standup()/changelog() methods and replaces the
three "not available in the browser" dispatch.ts stubs with real
handlers now that the backend endpoints exist (TASK-1894). Extracts
DashboardSuggestion as a shared type and adds StandupResponse /
ChangelogResponse types mirroring the Go response shapes.
* fix(server): make projectIntelVisibility bearer-aware (TASK-1894 codex R1)
standup/changelog's own item-list scoping used visibleCollectionIDs, which
has no bearer gate: a platform admin authenticated via a bearer token
(PAT/CLI/OAuth) who is only a restricted member of a workspace got the
unrestricted admin view instead of being scoped to their real membership.
Adds bearerAwareVisibleCollectionIDs, mirroring reportVisibleCollections'
existing BUG-1616/1617 gate, and switches projectIntelVisibility onto it
while preserving its item-level grant handling (which reportVisibleCollections
deliberately drops for aggregate reports).
buildDashboardResponse (and therefore /next, and standup's blockers/
suggested_next sections) is intentionally left ungated in this change —
gating it would break next's parity with dashboard.suggested_next and
diverge it from the CLI and MCP siblings. The resulting asymmetry is
documented inline pending a follow-up fix to buildDashboardResponse itself.
* docs(server): reference BUG-1917 in projectIntelVisibility comments
Replaces the textual placeholder ("the visibleCollectionIDs bearer-gate
bug filed from TASK-1894 review") with the actual bug number now that
it's been filed. Comment-only change, no behavior difference.
* fix(web): harden WebMCP scalar param readers against malformed types (TASK-1895)
str/num/bool now throw a precise tool error on a present-but-wrong-typed
value (e.g. a number where a string is expected) instead of silently
returning undefined, closing the scalar-validation residual from
TASK-1893's array hardening (strArray). Absent (undefined/null) and
empty-string params remain non-erroring, preserving existing optional-arg
semantics.
* fix(web): guard the action read inside dispatch()'s try/catch (TASK-1895 R2)
The action lookup (str(args, 'action')) sat outside the try/catch, so a
malformed action (e.g. action: 1) made str() throw unhandled and the
dispatch() promise rejected instead of resolving to an error envelope,
breaking register.ts's assumption that dispatch() always resolves.
Widen the try/catch to cover the action read through handler execution.
* fix(server): reserve parent/plan schema field keys (TASK-1912)
A collection schema field keyed exactly "parent" or "plan" makes the
parent-link extraction sites in handlers_items.go silently skip
fields-JSON extraction, disabling subtask linking with no error
anywhere. Reject newly-added occurrences of these keys on collection
create/update (grandfathering keys already present in a prior schema),
and add them to the web's reserved-key list so authors are steered
away before hitting the 400.
* fix(server): reject empty-string schema on collection PATCH (TASK-1912)
Codex round 2: handleUpdateCollection's validation guard was skipped
whenever input.Schema was a non-nil pointer to "", so a PATCH with
{"schema": ""} stored the empty string verbatim and every later
item-create against that collection 500'd instead of the mutation
being rejected up front. Drop the empty-string carve-out so "" flows
into json.Unmarshal, fails, and returns the existing 400 "Invalid
schema JSON". Omitting the schema field entirely (nil) is unaffected.
* feat(web): add copy-ID icon to item cards (IDEA-1904)
A hover-revealed copy button sits just right of the item ref on every
ItemCard (collection List/Board views + dashboard/starred/tags/share).
Reuses the existing copyToClipboard util and the item detail page's
clipboard→checkmark icon set; click calls preventDefault + stopPropagation
so it copies the issue ID without opening the card's <a> link.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(web): a11y for copy-ID button — SR announce + touch reveal (IDEA-1904)
Adversarial review flagged two real gaps in the copy-ID affordance:
- Copy success was visual-only; add a visually-hidden aria-live region
and a dynamic aria-label so screen readers hear "Copied IDEA-1904".
- The hover-only reveal (opacity:0) left it invisible on touch devices;
add an @media (hover: none) resting opacity like the star button, and
bump the tap target 18px -> 22px.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
`pad init` connects an agent (installs the skill, stores credentials) and
creates a workspace, but the web UI still showed the "connect an agent"
banner and onboarding launchpad. The only signal for "agent connected" was
has_agent_activity — an item existing with source cli/mcp — and a fresh
pad-init workspace has zero items, so the UI nagged to connect an agent the
user already had.
Give the server a truthful signal: a workspace created through an agent
surface already has an agent wired up before it creates its first item. Add
a `source` column to workspaces (web/cli/mcp), attributed authoritatively
server-side from the request auth shape (actorFromRequest) — never from the
request body, so a web client can't spoof "cli" to self-suppress the
prompts. The dashboard ORs source in (cli,mcp) into has_agent_activity when
the cheap item check comes up empty.
- migrations 069 (sqlite) / 047 (postgres): workspaces.source NOT NULL
DEFAULT '' (legacy rows stay "unknown", never treated as agent-created)
- models.Workspace.Source + WorkspaceCreate.Source (json:"-", server-set)
- thread source through the CreateWorkspace INSERT + all 7 workspace scan
sites (workspaces.go, workspace_members.go)
- handleCreateWorkspace derives source from actorFromRequest
- OnboardingLaunchpad step 1 collapses to "Agent connected" when the agent
is already wired up, shifting emphasis to "tell it to set up"
Web modal and cloud-signup auto-create flows are unchanged and still
correctly prompt to connect (source web / empty).
Tests: store source round-trip across reads; dashboard reports
agent-connected for a cli-created workspace with zero items; web-created
stays not-connected until an agent item exists; a web body-spoofed source
is ignored.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(admin): don't persist masked Maileroo API key on email settings save (BUG-1890)
The admin settings "Save Email Settings" button PATCHed the whole
platformSettings object. GET /admin/settings returns the Maileroo key
masked (abcd...wxyz for >8 chars, **** otherwise), so saving without
re-typing the key persisted the mask over the real key — silently
breaking email until re-entered.
Two layers:
- Client (+page.svelte): track whether the API-key field was edited
(apiKeyEdited flag) and scope the PATCH to the email fields this form
owns (mirrors the TASK-1889 Integrations save). The key is included
only when the admin actually edited it; an untouched save preserves
the stored key, and clearing the field still sends "" to disable.
- Server (handlers_admin.go): extract maskAPIKey() as the single source
of truth for the mask format and skip persisting maileroo_api_key when
the incoming non-empty value equals the mask of the currently-stored
key. Best-effort backstop for non-web/old clients; the client fix is
authoritative.
Tests (handlers_admin_settings_test.go): maskAPIKey unit cases, the
masked-key-not-persisted regression (both long and **** short masks),
real-key-update-wins, and empty-key-clears.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(admin): clear Maileroo key when disabling email provider (BUG-1890)
Codex review of the scoped email-save payload found a regression: when
an admin selects Provider "None" without touching the key field, the
scoped payload omitted maileroo_api_key, leaving the stored key. Because
reconfigureEmail keys email enablement off the presence of the API key
and ignores email_provider, "None" no longer disabled email.
Send an explicit empty key whenever the provider isn't Maileroo, so
disabling actually turns email off. The masked-key guard still applies
when the provider is Maileroo and the key was left untouched.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
* fix(server): tear down live email sender when platform key is cleared (BUG-1890)
Codex review: clearing the Maileroo key (e.g. disabling via provider
"None") wrote the empty key to the DB, but reconfigureEmail's empty-key
branch returned early without clearing the in-memory s.email sender —
so the running process kept sending mail until restart, contradicting
the UI's "disabled" state.
Track whether email was wired from env vars (emailEnvConfigured, set in
SetEmailSender). When platform settings carry no key, reconfigureEmail
now tears down the live sender (s.email = nil, emailAPIKey = "") unless
env config exists — env is the deployment baseline the admin UI doesn't
disable. Tests pin both the teardown and the env-preserved paths.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Add adjacent-column moves to the per-card kebab: left/right set the item's
group field to the neighbouring column value (left->right order) and land the
card at the top of the destination lane, reusing the drag status-change +
sort_order commit path (extracted commitColumnMove) with optimistic
source-lane removal so the card never double-renders. Left/right are a
separate optional onMove callback wired only by BoardView, leaving the vertical
onReorder type (and List/Table/Child hosts) untouched.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Activity rows (the dedicated Activity page and the dashboard's Recent
Activity list) showed only the item title, never the issue ID. Add the
ref (e.g. BUG-1748) as a leading monospace badge on both surfaces.
The ref rides on the per-row item lookup that already runs to populate
the title, so there are no new DB queries — enrichActivities and the
dashboard recent-activity builder now also copy item.Ref after
ComputeRef(). New item_ref field on models.Activity, DashboardActivity,
and the TS Activity / recent_activity types.
The Activity page now renders field changes as structured pills
("status: open → fixing") instead of a raw string, via a new shared
parseFieldChanges util that also replaces the private copy in
TimelineActivityCard.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Detail pages loaded the full content-bearing /items (~4.7MB) just to resolve
[[wiki-links]], stalling/timing out the page; list pages were fine because they
use the local-first localIndex read model. Move the detail page + editor [[ picker
onto localIndex (getAll accessor; zero extra fetch on warm nav). Harden SQLite:
bound the connection pool + periodic wal_checkpoint(TRUNCATE). Codex review clean
(P1 collab-flush ws, P2 inline-create ws — both fixed).
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Opening an item's detail page seeds the collab editor from server state and
arms a 5s-idle / on-unmount snapshot flush (PATCH ?source=collab-snapshot).
With no edits, that flush still re-PATCHed identical content, bumping the
row's updated_at/seq. In a "Manual"-sorted list — where every un-dragged
item shares sort_order=0 and the stable sort falls back to seq order — the
just-viewed item floated to the top. So merely viewing an item reordered it.
Fix: dedupe the flush against what the server already has. `lastFlushedContent`
covered the post-flush case but was null before any flush, so the first
(no-edit) flush always fired. Capture the item's loaded content as a per-item
`baseline` on the collab context (activeCollabContext) — it rides with the
flush so it survives the next item's load resetting the global
lastFlushedContent before this item's unmount-flush runs — and skip the PATCH
when the content to save equals `lastFlushedContent ?? baseline`.
Revert-safe: after a real flush lastFlushedContent is non-null and takes
precedence, so editing away then back still flushes. Verified in a browser:
viewing (empty + non-empty, past the 5s window and on navigate-away) no longer
writes or reorders; real edits still flush and save.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Add a small kebab menu to the top-right of each item with manual reorder
actions — move to top / up / down / move to bottom — as a menu-driven
counterpart to drag-to-reorder. Works on touch (board drag is disabled on
mobile) and in long lists where dragging is painful.
- New lib/collections/reorder.ts: pure, collision-safe dense-reindex helper
(+ unit tests) shared by every surface.
- New ItemActionsMenu.svelte: small dropdown portaled to <body> so it
escapes the content-visibility paint-clipping on virtualized rows;
positions on-screen on any viewport; aria-haspopup/expanded + arrow-key
nav + focus return; close-on-pick concurrency guard.
- Wire into ItemCard (opt-in onReorderItem/reorderDisabledDirs props),
ListView, BoardView (lane-relative; menu enabled on mobile where drag is
off), ChildItems (own optimistic reorder + update loop), and TableView
(now honors manual sort_order + onReorder + an actions column; a
column-header sort transparently hides the menu).
Gate everywhere mirrors the drag gate: canEdit && manual sort && !search.
Read-only/aggregation views (share pages, starred, tags, role board) pass
no callback, so no menu renders there.
Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
Set annotations.untrustedContentHint=true on WebMCP tool descriptors for
every catalog tool whose output surfaces user-authored workspace content.
Signals the browser agent to treat results as unverified input
(prompt-injection hardening, DR-2). Derivation is a small pure helper
(surfacesUntrustedContent) parallel to isAllReadOnly; the builder stays
side-effect-free.
The rule is derived from the action set, not the tool name: a tool surfaces
content unless EVERY action is content-free server introspection (server-info
/ version / tool-surface). This correctly flags pad_meta — despite its name it
exposes a `bootstrap` action that returns the workspace bootstrap blob (user +
workspace content) — while still exempting a hypothetical pure version/meta
surface.
Add a consent manual-verification checklist (web/src/lib/webmcp/README.md)
capturing the Chrome-149 origin-trial steps that can't run in CI: read tools
run quiet, mutating tools prompt per-invocation consent, no workspace arg is
offered, untrusted-content honesty.
Refs TASK-1896 / PLAN-1888
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
Wire every mutating catalog action in the browser WebMCP dispatcher to a
real api client method, completing the full-surface scope (DR-5). Each new
dispatch entry forces the route wsSlug and rejects an agent-supplied
workspace arg (DR-4); no write action silently no-ops.
Actions wired:
- pad_item: create, update, delete, restore, move, link, unlink, star,
unstar, comment, bulk-update, import (export/deps/list-comments/starred
reads also added)
- pad_collection: create, update, delete
- pad_role: create, update, delete
- pad_playbook: run (side-effect-free)
- pad_library: activate (resolve-by-title, mirrors the CLI)
- pad_meta: bootstrap (scope addition — GET /agent/bootstrap)
Thin client.ts methods added: playbooks.run, agentBootstrap,
library.activateByTitle.
Consent (DR-2): mutating tools mix reads+writes so descriptors emit no
readOnlyHint — the browser fires per-invocation consent before execute
runs. This dispatcher is the post-consent execution layer, not the gate.
pad_project next/standup/changelog left as honest-error stubs (need new
backend endpoints, tracked in TASK-1894).
Refs TASK-1893 / PLAN-1888
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* feat(web): WebMCP browser module — register catalog tools (read dispatch)
Add web/src/lib/webmcp/: a feature-detected, webmcp_enabled-gated module
that registers Pad's MCP catalog tools via document.modelContext, scoped to
the current-route workspace, dispatching READ actions to the api client.
Phase 3 of PLAN-1888 (TASK-1892). Builds on the Phase-1 session flag and
the Phase-2 GET /api/v1/mcp/tool-surface endpoint.
- types.d.ts: experimental ambient stub for document.modelContext.
- api.mcp.toolSurface() + ToolSurface* types; api.playbooks.list/get.
- descriptors.ts (pure, unit-tested): tool-surface JSON → ModelContextTool[],
STRIPPING the workspace param (DR-4) and setting readOnlyHint only when
EVERY action is read_only (DR-2).
- dispatch.ts (pure, injectable, unit-tested): (tool, action, args) → client,
forcing the route wsSlug and REJECTING any agent-supplied workspace (DR-4).
Write actions return a precise "wired in TASK-3b" error — never a no-op.
- register.ts: lifecycle entry; gated on webmcp_enabled + feature-detection +
auth; AbortSignal teardown.
- +layout.svelte: register on the workspace $effect, tear down on switch /
onDestroy, with a token guard against stale async registration.
- vitest wired (25 unit tests).
Refs TASK-1892 / PLAN-1888
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* fix(web): WebMCP lifecycle — invalidate in-flight reg on unmount; re-register on auth resolve
Codex review round 1 (both P1/P2):
- onDestroy now bumps webmcpToken before teardown so an in-flight
registerWorkspaceTools() awaiting the tool-surface fetch self-discards
(its .then sees token mismatch and unregisters) instead of leaking.
- New standalone $effect re-runs connectWebMCP() when the auth gate
(webmcp_enabled + user) resolves after the untrack'd workspace effect
already ran — fixes cold-load registration being permanently skipped.
Idempotent (token guard ensures no dup registration). Kept as a separate
effect per CONVE-606.
Refs TASK-1892 / PLAN-1888
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* chore(docs): correct cloud MCP from "future /mcp endpoint" to live mcp.getpad.dev vhost
The HTTPHandlerDispatcher description called the remote MCP server a
"future /mcp endpoint." It's live: a cloud-mode-gated Streamable HTTP
server mounted on the dedicated mcp.getpad.dev vhost via
SetMCPTransport / registerMCPRoutes. Point at handlers_mcp.go.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* feat(server): add webmcp_enabled platform setting + session flag
Introduce the opt-in gate for the browser-side WebMCP surface
(PLAN-1888 Phase 1, DR-6). New webmcp_enabled platform setting,
default off, admin-writable, surfaced to the web client via the
/api/v1/auth/session payload so client tool registration can gate
on it.
- internal/server/handlers_admin.go: add settingWebMCPEnabled to the
admin-PATCH whitelist (else silently dropped) + serialize a "false"
default in the GET settings response.
- internal/server/handlers_auth.go: emit webmcp_enabled in the session
payload via a fail-closed webMCPEnabled() helper (false on unset or
read error).
- web/src/lib/api/client.ts: add webmcp_enabled?: boolean to AuthSession.
- web/.../console/admin/settings/+page.svelte: Integrations section with
a WebMCP toggle + security warning copy (Phase 4 admin-warning intent).
- Go tests: admin PATCH persists + non-admin 403; session payload reflects
stored value with default false.
No migration (platform_settings is an existing kv table).
Refs TASK-1889 / PLAN-1888
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
Add a loopback-only account-recovery path so a self-hosted operator who
forgot their password (with no email provider configured) can recover
without editing the database by hand.
- POST /api/v1/auth/local-reset: loopback-gated, non-cloud, no auth
required (same trust model as bootstrap). Returns a single-use reset
link, or a temporary password with {"temp_password": true}.
- pad auth reset-password <email> [--temp-password]: talks to the local
server over loopback directly (not the configured public URL), so the
command works on the server host regardless of CLI config. Prints the
server's shareable reset_url when a public base URL is known.
- Web /forgot-password reads email_configured from the session and shows
host-recovery instructions instead of a dead "we emailed you a link"
when no provider is configured.
- forgot-password server log emits the reset path on non-cloud instances
so operators can also recover straight from the logs.
- Docs: CLAUDE.md + docs/deployment.md recovery sections.
Tests cover the loopback/cloud gates, the shareable reset_url, and both
output modes (reset link + temp password).
* feat(artifact): web UI for playbook/convention export & import
Phase 4 of PLAN-1867.
- API client: exportItemArtifact (GET, parses Content-Disposition filename)
and importArtifact (POST raw text/markdown); ImportArtifactResult type.
- Export buttons: playbook editor header, each playbook card, each convention
row — client-side .pad.md download.
- Import file-picker entry points on the playbooks list + conventions header;
surfaces per-warning toasts + a deep link to the created draft item.
Shared browser helpers in lib/utils/artifacts.ts. CONVE-1688/606 compliant.
Implements TASK-1878, TASK-1879, TASK-1880.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* fix(artifact): web import race + RFC5987 filename parse
Addresses Codex Phase-4 review:
- parseContentDispositionFilename now decodes general filename*=charset'lang'value
(percent-decoded), not just a UTF-8'' prefix.
- import handlers snapshot workspace/username before the first await, so a
mid-import workspace switch can't misroute the follow-up get/link/refresh.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
buildTestInvocation now returns a surface-neutral `nl` form ("run the <slug>
playbook …") as the first/canonical field; the playbook editor's
"Test invocation" helper leads with a "Natural language — works anywhere"
block and frames the Claude Code / CLI / MCP forms under "Or use the shortcut
for your agent:".
The library card invocation chip changes from `/pad <slug>` to `▶ <slug>`
with an NL-canonical tooltip that lists the per-surface shortcuts — the web
UI can't know which agent the user runs, so this is honest reframing rather
than a per-tool chip.
Parent: PLAN-1858.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
Adds a compact "Setup progress" checklist to OnboardingLaunchpad
(✓ Workspace created · ○ Agent connected · ○ Setup complete) using existing
signals only — agentActive is wired from dashboard.has_agent_activity, no
new backend tracking.
Honesty caveat (documented on the prop): has_agent_activity flips on the
first agent-CREATED item, which also clears needs_onboarding and removes the
launchpad — so the checklist is an orientation device (you're on step 1),
not a live mid-launchpad tracker. A distinct "connected but hasn't acted
yet" signal is deferred to IDEA-1854.
Parent: PLAN-1847 (Phase 3) — final Phase 3 task.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
A user who clicks "Skip setup" on an empty workspace landed on a board with
no collections and no surfaced way to create one (collections lived behind
Settings) — declining an agent was effectively a dead end.
When onboarding is dismissed and the workspace has no user collections, owners
now see an "Your workspace is empty" card with a "+ Create a collection"
button (opens the existing CreateCollectionModal) and a "Show setup guide"
link back to the launchpad. Owner-gated to match the server's
create-collection boundary; hidden the moment any user collection exists.
Non-owners and already-populated boards keep the existing slim reshow link.
Parent: PLAN-1847 (Phase 3).
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
Intent-as-seed for the onboarding bridge. The workspace `description` column
already existed end-to-end but nothing captured or surfaced it:
- Web: CreateWorkspaceModal gains an optional "What are you tracking?"
textarea (create-only), sent as `description` on create.
- Bootstrap: AgentBootstrapWorkspace now carries `description` (omitempty,
additive) so the onboard playbook can read the user's stated intent.
- Onboard playbook: pre-flight reads workspace.description; B1 reflects it
back ("You mentioned this is for X — let's build around that") instead of
opening cold with "what is this project?", falling back when absent.
Net effect: a user who types one line at creation gets an onboard interview
that starts warm instead of from zero.
Parent: PLAN-1847 (Phase 3).
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* feat(web): aha-highlight the first agent-created item (TASK-1853)
Closes the sprint-to-aha loop for the onboarding bridge: when an agent
creates the first real item during onboarding, its dashboard card gets a
"✨ your agent just created this" badge + accent, so value lands visibly
for the human in the live launchpad→board handoff.
Detection is a slug-keyed effect on needs_onboarding: when it flips
true→false within the same workspace (driven by the existing SSE→sync→load
path), the current active_items slugs are captured and their cards
highlighted for the session. Scoped tightly to the live transition — a
later page load (needs_onboarding already false) fires no transition, so
routine creates are never highlighted; the slug key prevents a workspace
switch from false-positiving.
Satisfies CONVE-1688 (the effect writes justCreatedSlugs but never reads
it) and CONVE-606 (untrack-wrapped, route-change-aware).
Parent: PLAN-1847 (Phase 2, task C).
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* fix(web): key aha-highlight on dashboard data's slug, not route param, per Codex review (round 1)
Round-1 review caught a workspace-switch race: a silent reload leaves the
old dashboard (needs_onboarding=true) in state while wsSlug already flipped,
so the route-keyed edge recorded onboarding=true under the new slug and then
false-positived when the new workspace's data (false) arrived — highlighting
all of the new workspace's items.
Stamp the dashboard with the slug it was fetched for (dashboardSlug) and key
the transition on that. The true→false edge is now only ever computed across
two loads of the SAME workspace's data.
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* feat(web): launchpad render-mode for unset-up workspaces (TASK-1852)
While `needs_onboarding` is true, the workspace dashboard rendered as an
empty board with a nudge banner on top — which reads as broken, not new.
Replace that with a dedicated setup launchpad (OnboardingLaunchpad.svelte):
a three-step bridge that routes the user into the agent-driven onboard flow
— ① connect an agent (opens the existing ConnectWorkspaceModal), ② tell it
"set up my workspace" (NL-canonical, with per-surface shortcuts), ③ watch
the result appear live via the page's SSE feed.
It's a render-mode keyed on the existing flag, not a new route: while
needs_onboarding && !dismissed the launchpad renders instead of the board;
"Skip setup" falls through to the board with a reshow affordance; the flag
flips false on the first real item, swapping back to the normal dashboard.
The launchpad supersedes OnboardingNudgeBanner (TASK-1851) — it had no other
consumer, so it's deleted; its NL-canonical copy carries over into step ②.
Parent: PLAN-1847 (Phase 2, task B).
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
* refactor(web): drop unused wsSlug prop from OnboardingLaunchpad
Self-review cleanup: wsSlug was declared in Props and passed by the parent
but never used in the component (it opens the connect modal via callback).
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
OnboardingNudgeBanner hardcoded `/pad onboard`, which contradicted the
NL-canonical messaging shipped in TASK-1849 (and is wrong for non-Claude-Code
agents). Lead with natural language ("say set up my workspace") instead — the
universal trigger that works regardless of which agent the user connects.
ConnectBanner audited — no slug reference, left as-is. Per-surface shortcut
rendering belongs in the connect modal's surface-aware kickoff (TASK-1852),
where the chosen tool is known.
Parent: PLAN-1847 (Phase 2, task A).
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
Add the server build version to both the setup-state and authenticated
/auth/session payloads (same source as /health). The mobile shells call
/auth/session on connect; surfacing version there lets them read it in
the round-trip they already make and warn when a server is below their
minimum supported version, without a second request (IDEA-1826).
Keep the web AuthSession TS type in sync (CONVE-1741).
On a fresh instance, `pad init` / `pad auth setup` created the admin
account in the browser and dropped the operator on the console, then
printed a SECOND "authorize the CLI" URL back in the terminal that a
user who'd moved to the browser never saw — forcing a ctrl-C + re-run.
Collapse it into a single browser tab: the CLI mints the pending CLI
auth session up front and hands /setup a validated `next=/auth/cli/<code>`
target, so account creation flows straight into the approval page where
the just-bootstrapped admin approves in one click and the CLI connects.
- internal/cli/bootstrap.go: thread `next` into the /setup URL (query
before the #token fragment); raise bootstrapPollTimeout to 20m to
match the setup session TTL.
- cmd/pad/main.go: extract pollAndSaveCLIAuth; runBrowserSetup pre-creates
the session and polls it; `pad workspace init` drives local setup inline.
- cmd/pad/init.go: `pad init` routes through the unified handoff.
- internal/store + internal/server: grant a setup-specific 20m CLI auth
session TTL when UserCount==0 so the combined create-account + approve
window can't expire mid-flow; normal logins keep the 5m default.
- web/src/routes/setup: honor a validated local `next` redirect (open-
redirect guarded), preserved across the token-fragment scrub.
Reviewed via Codex loop (3 rounds → clean).
Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
Board-active collection pages hardcoded height:100vh, but the page
lives inside .main-content which is already sized below the workspace
TopBar via flexbox. With the bar visible the board overflowed by the
bar's height (extra scrollable space / too-tall lanes); with it hidden
100vh coincidentally matched. Use height:100% so it fills .main-content
in both states.
Promote MobileContextBar from a detail-only back+title bar to the persistent
mobile top bar shown on every screen inside a workspace. The collection/item
name now reads the same everywhere and the workspace switcher (moved out of the
bottom-nav sheet) is always visible for at-a-glance context and quick switching.
- MobileContextBar: show on all workspace screens (was depth >= 2); gate the
back affordance to non-root screens; embed WorkspaceSwitcher on the right,
restyled onto --bg-tertiary and sized to fill the 44px bar.
- WorkspaceSheet: drop the switcher card + inline list (now in the top bar);
the "Workspace" bottom-nav slot keeps its label/avatar and Navigate +
Collections.
- Collection list: hide the now-duplicate in-page collection-name heading on
mobile; the name lives in the bar.
Follow-up to TASK-1829 (Codex review of #735). When the open item was
archived live — via the SSE item_archived handler or the sync-resume
deleted path — the detail route redirected back to the collection, so the
new in-place Archived banner only appeared on a fresh direct load. Now both
handlers re-fetch the item (GET returns soft-deleted items with deleted_at,
#733) and render the banner in place.
- SSE item_archived: re-fetch and show the banner instead of goto().
- Sync-resume deleted: re-fetch — an archived item (still resolvable, 200)
shows the banner; a hard-deleted one (404) still redirects.
- Both keep the prior redirect when mid-edit (saveStatus==='saving' ||
editingTitle): an in-flight save against an archived row would fail and a
re-fetch would clobber the editor (the original Codex-round-2 reasoning).
- Race guards mirror the handlers' existing pattern (capture item id before
await, bail if navigated away). The actor's own archive still navigates
via handleDelete's goto; this only changes the someone-else-archived-it case.
svelte-check + web build green.