mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
4f0984bb151d6b43177bc7845c6c8a4aabb5e976
440 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e056e9c3f4 |
feat(web): NL-canonical playbook invocation in the editor + library (TASK-1860) (#750)
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
|
||
|
|
408e578567 |
feat(web): setup-progress checklist on the launchpad (TASK-1857) (#748)
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 |
||
|
|
4a99f5f213 |
feat(web): manual escape hatch on the skipped-onboarding board (TASK-1856) (#747)
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 |
||
|
|
fa8064e2b9 |
feat(onboard): capture workspace intent at creation, warm the onboard run (TASK-1855) (#746)
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
|
||
|
|
80a939fe3c |
feat(web): aha-highlight the first agent-created item (TASK-1853) (#745)
* 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 |
||
|
|
ac43e490e1 |
feat(web): launchpad render-mode for unset-up workspaces (TASK-1852) (#744)
* 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 |
||
|
|
1d62035f28 |
feat(web): NL-canonical onboarding banner copy (TASK-1851) (#743)
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
|
||
|
|
14614c98f7 |
feat(auth): surface server version on /auth/session (TASK-1839) (#740)
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). |
||
|
|
22d901c823 |
fix(auth): unify first-run setup into one browser handoff (BUG-1843) (#739)
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 |
||
|
|
62fafc49c6 |
fix(web): kanban board height accounts for workspace top bar (BUG-1844) (#738)
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. |
||
|
|
c93f2aa12c |
feat(web): unified mobile top bar with always-on workspace switcher (IDEA-1835) (#737)
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. |
||
|
|
8c8e858e1a |
feat(web): show archived banner in-place on live archive (TASK-1833) (#736)
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. |
||
|
|
372d95cb2a |
feat(web): archived-item recovery on the item detail page (TASK-1829) (#735)
With GET now returning soft-deleted items read-only (deleted_at populated, shipped in #733), the detail route can show an archived item instead of a hard 404. Adds the recovery UI: - isArchived derived from item.deleted_at; folded into the existing canEdit derived so every edit affordance disables while archived. - A read-only "Archived" banner at the top of the item view (date via the file's relativeTime helper) with a Restore button -> api.items.restore, then re-fetches the item so the banner clears and editing re-enables. Success/error via the existing toastStore; a 409 reclaimed-slug conflict surfaces verbatim. - restoring in-flight flag; handleRestore is a standalone async function (not an effect) per CONVE-1688 / CONVE-606. No API/client/server change — GET is already ungated (#733) and api.items.restore already existed. Child of BUG-1791 (TASK-1827 in #733, TASK-1828 in #734). |
||
|
|
45d032cb09 |
fix(web): graph node click/dbl-click + Open as real link (TASK-1788) (#725)
* fix(web): graph node click/dbl-click + Open as real link (TASK-1788) Three interaction fixes for the per-item graph (TASK-1787 follow-up): - Click-to-select and double-click-to-zoom didn't fire. Root cause: the viewport called setPointerCapture on pointerdown, which suppressed the SVG nodes' click/dblclick events. Now drag/capture engages only after the pointer moves past a 4px threshold, so a plain press still produces a node click; a gesture that became a pan suppresses the trailing click via a flag. - Open actions are now real <a href> links (controls "Open ↗" and the detail panel "Open item ↗"), so cmd/ctrl-click opens the item in a new tab; a plain click is a normal SvelteKit navigation that closes the drawer via the ?graph URL effect. Replaced the onOpenItem callback prop with an itemHref builder. Parent: PLAN-1780. * fix(web): robust pan/click suppression per Codex review (round 1) - Clear suppressClick when the drag gesture ends (deferred one tick so the trailing click is still suppressed) instead of waiting for the next node click. Fixes a pan ending on empty canvas leaving the flag set and swallowing the next intentional click. - Guard onPointerMove on e.buttons: if the primary button isn't held (press ended off-viewport before a drag engaged, so no pointerup was seen), abort instead of starting a ghost pan when the pointer returns. Parent: PLAN-1780. |
||
|
|
bd16b9b1b6 |
feat(web): graph view enhancements — larger drawer, node detail panel, toggleable legend, bigger cards (TASK-1787) (#724)
* feat(web): graph view enhancements — larger drawer, node detail panel, toggleable legend, bigger cards (TASK-1787) 1. Drawer opens wider — min(1500px, 94vw) — to take up the majority of the page. 2. Single-clicking a node selects it and opens a detail panel (ref, title, collection, status, terminal/child-count) with "Open item ↗" and "Focus here" (re-root) actions. Re-root/open are now explicit panel actions rather than bare-click behaviors, so a stray click can't navigate. 3. Legend entries are buttons that toggle a collection's visibility — hidden collections (and edges touching them) drop out via derived filters without recomputing the dagre layout, so positions stay stable. 4. Bigger node cards (212x60) showing up to two title lines; double-clicking a card zooms/centers on it. Parent: PLAN-1780 (follow-up). Drawer width lives in the item page; the rest in ItemGraph.svelte. Validated with svelte-check (0 errors) + build. * fix(web): overlay clicks don't start a pan + Fit frames visible nodes per Codex review (round 1) - onPointerDown ignores presses that land on an interactive overlay (legend, detail card, error retry) via a target.closest check, so clicking those controls no longer begins a viewport drag / pointer capture. Avoids adding pointerdown handlers to static divs (keeps svelte a11y clean); detail card role dialog→group (no tabindex requirement, supports aria-label). - fitView now computes bounds from the currently VISIBLE nodes (falling back to full bounds when none visible), so Fit frames what's on screen after hiding collections instead of centering on invisible nodes. Parent: PLAN-1780. * fix(web): Fit no-ops when all collections hidden per Codex review (round 2) currentBounds() now returns null when no nodes are visible (instead of falling back to the full graph bounds), so fitView() — including the post-reroot queueFit — no-ops rather than recentering on the hidden graph. Removed the now dead full-graph bounds tracking (contentBounds + runLayout bounds computation). Parent: PLAN-1780. |
||
|
|
a37fa16356 |
feat(web): deep-link the dependency-graph drawer via ?graph=1 (TASK-1786) (#723)
* feat(web): deep-link the dependency-graph drawer via ?graph=1 (TASK-1786)
- The item page auto-opens the graph drawer when loaded with ?graph=1, so
item pages / standup / chat can link straight into the view
(/{user}/{ws}/{collection}/{ref}?graph=1). One-shot capture mirrors the
existing ?new=1 pattern; always reassigned so a stale open state can't
linger onto a subsequent item.
- Opening/closing the drawer syncs the ?graph param (replaceState, no scroll)
so the open state is shareable and back-button-aware; setGraphParam no-ops
when already in sync to avoid redundant navigation.
- Centralized close via closeGraph() across the backdrop, Close button, and
Escape handlers.
Parent: PLAN-1780.
* fix(web): stale-guard deep-link auto-open + correct history claim per Codex review (round 1)
- Capture the ?graph intent at request scope (reqGraph) and only apply the
drawer open/close in loadData's finally when the load is still the current
route (req slugs match). Prevents a late-finishing earlier load from forcing
showGraph against a newer route.
- replaceState keeps ephemeral drawer toggles out of history by design; dropped
the inaccurate "back-button-aware" claim from the comment (shareable is the
real property).
Parent: PLAN-1780.
* fix(web): apply graph deep-link only on navigation, not data refreshes per Codex review (round 2)
loadData() runs for same-item SSE/sync refreshes as well as navigation. The
finally block forced showGraph from the URL on every run, so a refresh could
stomp a drawer toggle the user made mid-refresh. Thread an isNavigation flag
(true only from the route-param effect) so refreshes never touch showGraph;
the deep-link auto-open fires only on actual navigation.
Parent: PLAN-1780.
* refactor(web): make ?graph the single source of truth for the drawer per Codex review (round 3)
Same-slug ?graph navigations and browser back/forward update page.url without
retriggering loadData, so the prior loadData-coupled approach left showGraph
stale for search-param-only navigations. Drive showGraph from a dedicated
effect that watches the ?graph param instead:
- Handles initial load, navigation, back/forward, and param-only changes
uniformly; data refreshes never touch it (they don't change the URL), which
also resolves the earlier refresh-stomp finding.
- open/close just flip the param (setGraphParam); the effect reflects it. The
lazy component load is deferred to a microtask so reading ItemGraphComp
doesn't register as an effect dependency (no reactivity tangle).
- Added retryGraphLoad() for the import-failure Retry (param already set, so
setGraphParam would no-op). Removed the now-unused loadData coupling
(isNavigation flag, reqGraph capture, finally block).
Parent: PLAN-1780.
|
||
|
|
4456030938 |
feat(web): live SSE updates in the dependency-graph drawer (TASK-1785) (#722)
* feat(web): live SSE updates in the dependency-graph drawer (TASK-1785) Subscribe ItemGraph to the workspace SSE stream while open so the graph reflects changes live: - Correlate events to visible nodes by item UUID (added id to RenderNode). - item_updated: glow the node + debounced BACKGROUND refetch (no spinner, no view-refit) to pick up status/title/terminal changes. - item_created/archived/restored: debounced refetch (structural). - comment_created: glow only (ambient liveness). - onSyncRequired (bulk updates / replay-gap backfills): folded into the same debounced refetch. Refactor the load path into load(background): background mode keeps the last good graph visible and swallows errors so an ambient blip never replaces a working view with an error card. Transient node glow auto-clears after 2.5s; a small "updating…" indicator shows during a background reload. Subscription is set up in onMount and torn down on close (drawer-scoped). Parent: PLAN-1780. * fix(web): gate SSE refetch to in-view items + invalidate load on teardown per Codex review (round 1) - handleItemEvent now no-ops unless the event's item is in the current neighborhood, so unrelated workspace edits no longer trigger background refetches against api.graph.getFocused. A newly-linked neighbor still surfaces: linking touches the visible endpoint (item_updated, in-view) and that refetches. item_created is ignored (never already in view). - Bump loadToken in the onMount teardown so an in-flight load can't commit $state or queue fitView after the drawer closes / component unmounts. Parent: PLAN-1780. * fix(web): clear pending glow timers on teardown per Codex review (round 2) The per-node glow setTimeout could fire after unmount and write touchedRefs on a destroyed component. Track the handles and cancel them in teardown, matching the refetchTimer/loadToken teardown discipline. Parent: PLAN-1780. * fix(web): burst-safe node glow with per-ref timers per Codex review (round 3) touch() now resets a per-ref fade timer instead of stacking timeouts, so a burst of events for the same node keeps it lit and the fade starts GLOW_MS after the LAST event. Replaced the one-shot keyframe with a transition-based glow (filter transition on .node-bg) so rapid re-touches don't fight an animation that can't restart — the node stays lit while touched and fades out when the ref leaves touchedRefs. Kept onSyncRequired → debounced refetch (round 3 finding 2): it's the staleness-recovery net for bulk-update and replay-gap-reconnect events, which are rare and carry no item_id to filter on. The 400ms debounce collapses bursts to a single background reload, so it's not a per-edit refresh path. Parent: PLAN-1780. * fix(web): guard background refetch until first graph is ready per Codex review (round 4) scheduleRefetch() now no-ops unless loadState === 'ready'. Previously an onSyncRequired/SSE event during the initial foreground load could start a background load(true) that bumped loadToken (cancelling the foreground load) while skipping fit + error handling — leaving the view un-fitted, or stuck on the spinner forever if that background fetch failed. The in-flight initial load already fetches the latest data, so dropping events before the first ready render loses nothing. Parent: PLAN-1780. * fix(web): defer (don't drop) refetches during initial load per Codex review (round 5) The loadState !== 'ready' guard prevented the spinner/fit race but dropped the recovery signal for mutations arriving mid-initial-load: if a change lands after the initial request is issued, that response can be stale and no later event would correct it. Now such requests set a pendingRefetch flag that load() flushes once it commits a ready graph, so the neighborhood always reconciles. Parent: PLAN-1780. * fix(web): foreground load cancels pending background refetch (root-cause) per Codex review Close the load/refetch race class generally rather than per-path: - A foreground load (initial / reroot / depth / include-done) now clears any armed background refetch timer at start, so the delayed callback can't fire later, bump loadToken, and cancel the foreground load (which on a background failure left the view stuck on the spinner). - The background timer callback re-checks loadState === 'ready' at fire time and defers (pendingRefetch) instead of clobbering if state has moved on. Parent: PLAN-1780. * fix(web): refetch on all item events (not just in-view) per Codex review Reconcile the refetch-breadth tradeoff in favor of correctness. The server publishes structural link changes (new child, reparent) on the CHILD — which may be outside the current neighborhood — and the SSE payload carries no link info, so gating refetch on in-view items missed newly-attached/reparented neighbors. Refetch on any item_updated/created/archived/restored instead; glow stays in-view-only (off-view items have no node to light up). Cost is bounded by the 400ms debounce (one small getFocused per burst) and the drawer's transient lifetime. Parent: PLAN-1780. * fix(web): coalesce in-flight refetches + recover from error per Codex review (round 8) Two concurrency fixes for the SSE refetch path: - Coalesce: a loadInFlight guard prevents starting a new background load while one is awaiting. Previously a steady event stream (spaced > debounce but faster than getFocused+layout) kept bumping loadToken and invalidating in-flight responses, so live updates could starve forever. Now at most one load runs at a time; deferred events flush (exactly once) when it settles. - Error recovery: refetch decisions moved into runRefetch(), which retries foreground from the 'error' state on the next live event. Previously events during/after a failed load only set pendingRefetch (flushed solely on a successful ready), so the graph could stay stuck in error until a manual retry. pendingRefetch is now flushed from load()'s finally on every outcome. Parent: PLAN-1780. |
||
|
|
4bd048c8fc |
feat(web): item-header Graph button + dependency-graph drawer (TASK-1784) (#721)
Wire the 2D ItemGraph renderer into the item detail page:
- New "🕸 Graph" action button in the item header (shown when the item has
an issue ref) opens a right-docked drawer overlay (full-screen on narrow
viewports) hosting ItemGraph, focused on the current item.
- The renderer is dynamically imported on first open, so it (and its dagre
layout lib) stay out of the item-page bundle.
- Backdrop click, Close button, and Escape all dismiss; the ESC listener is
only attached while the drawer is open.
- Clicking a node's open action navigates to that item, building the
/{user}/{ws}/{collection}/{ref} URL — ItemGraph.onOpenItem now passes the
node's collection so the URL is correct without a lookup.
Parent: PLAN-1780.
|
||
|
|
771d102e89 |
feat(web): 2D directional dependency-graph renderer (TASK-1783) (#720)
* feat(web): 2D directional dependency-graph renderer (TASK-1783)
Add ItemGraph.svelte — a reusable, self-contained component that renders a
single item's dependency neighborhood as a 2D layered graph (parents above,
children below) via dagre layout + SVG.
- Lazy-imports @dagrejs/dagre so the layout lib stays out of the main
bundle (confirmed split into its own chunk).
- Fetches via api.graph.getFocused; re-roots on an internal currentFocus so
clicking a non-focus node navigates the chain, clicking the focus node (or
Open ↗) opens it. Depth (1–5) selector + include-done toggle; breadcrumb
back to the original root.
- Edge styling by type (hierarchy tethers, red directional blocks arrows,
faint wiki-link dashes, muted others); collection-colored nodes with
focus highlight + dimmed terminals; pan/zoom + fit-to-content; legend and
a truncation notice.
- Reactivity follows CONVE-1688 (loadToken guard, no read+write of one rune
in an effect) and CONVE-606 (data-load effect split from prop-sync).
Extract the collection palette into a shared $lib/graph/palette.ts and point
the 3D workspace graph at it too, so both views agree on collection colors.
Parent: PLAN-1780.
* fix(web): correct hierarchy layout direction + node border color-mix per Codex review (round 1)
- P1: 'parent'/'implements' edges are child→parent in the API, so feeding
them to dagre as-is put children above parents under rankdir TB. Reverse
those edges for layout ranking only; rendered edges keep true source→target
so 'blocks' arrowheads still point correctly.
- P2: unfocused node border used a JS string literal with an un-interpolated
{n.color}, yielding an invalid CSS color and a dropped stroke. Use a
template literal so the collection tint actually applies.
Parent: PLAN-1780.
|
||
|
|
f45a45a142 |
feat(web): graph.getFocused client + truncated field for focus mode (TASK-1782) (#719)
Surfaces the TASK-1781 backend focus mode to the web client:
- GraphResponse gains an optional `truncated?: boolean` (focus-mode-only;
absent for the whole-workspace view).
- New api.graph.getFocused(ws, focusRef, { depth?, includeTerminal? })
builds ?focus=REF&depth=N[&include_terminal=true]. The existing
graph.get is unchanged so the 3D view keeps working.
Parent: PLAN-1780.
|
||
|
|
dc8cb783d9 |
feat(web): surface Apple as a linked provider in console settings (TASK-1777) (#715)
Follow-up to TASK-1773: the backend now persists 'apple' in oauth_providers and accepts it for unlink, but the settings UI hardcoded [github, google] — a native-Apple user (PLAN-1772) had an invisible, unmanageable linked provider. Linked Accounts is now a data-driven list with a webLinkable flag. GitHub/Google have a /auth/<provider>/link redirect flow; Apple does NOT (Sign in with Apple is native-iOS-only — no web link route). So Apple appears with a Linked badge + Unlink once linked from the app, but is never shown a 'Link' button (an unlinked Apple row with no action would be noise — it's hidden until linked). Unlink works for all three (the server accepts apple, TASK-1773). Provider-name lookup replaces the github/google ternaries in the unlink + link-redirect-status messages. Login page + lastMethod intentionally unchanged: provider=apple can't reach the web /login?error= path (the native Apple endpoint returns JSON to the app, never a browser redirect), and the existing '... ? provider : null' collapse already renders a correct generic fallback. Adding web Apple retry CTAs would be wrong — they'd 404, since there is no web Apple flow. Revisit only if web Apple sign-in is added. |
||
|
|
00d4fe066b |
fix(web): quick-capture docks above bottom nav, mutually exclusive with other sheets (BUG-1765) (#713)
The center + set captureOpen without closing the other nav surfaces and wasn't a toggle, so tapping + with Search open stacked both sheets. QuickCaptureSheet now presents as a DockedSheet (anchored above the nav, like Workspace/You and the search palette) and BottomNav routes all four surfaces through a shared closeAllSurfaces() so opening any one closes the rest; a second tap on + collapses it and the slot lights while open. |
||
|
|
ba734d55e7 |
fix(editor): symmetric table cut/paste — structural row cut, TSV cell reconstruction (BUG-1247) (#712)
* fix(editor): table cut removes rows structurally; paste expands TSV into cells (BUG-1247)
Cut whole-row CellSelection now calls deleteRow (structural transform,
correct undo in one step) instead of deleteSelection (content-clear).
If all rows are selected the table is removed entirely via deleteTable.
Partial cell selections continue to clear content, preserving the
ProseMirror semantic for partial cuts.
Add a paste handler in tableCopyPlugin: when the anchor is inside a
table cell, text/plain contains tabs or newlines, and text/html carries
no <table> element, parse the TSV into a 2D grid and fill successive
cells from the anchor position, clamping at the table's right/bottom
edges (no table growth). Spreadsheet pastes with <table> HTML fall
through to ProseMirror's existing HTML handler unchanged. Single-value
paste (no tab/newline) falls through to tiptap-markdown's transform.
Known limitations documented in code: RFC-4180 quoted multi-line cells
are not parsed (naive \n split), and paste overflow is clamped/dropped.
* fix(editor): iterate paste cells in reverse to avoid stale doc positions (BUG-1247)
The previous forward-order loop computed all cellPos values from the
pre-transaction TableMap, but each tr.replaceWith shifts subsequent
doc positions. From the second fill onward, stale offsets indexed into
a doc that had already been mutated — wrong cells were written or (with
large size deltas) replaceWith would corrupt the document silently.
Test C in verify-paste.cjs demonstrated the corruption concretely:
'NEW-R1C0' was dropped and 'NEW-R1C1' landed inside an adjacent cell's
content, producing 'loNEWNEW-R1C1'.
Fix: collect cells to fill, reverse the list (bottom-right → top-left),
then re-read each cellNode from tr.doc at its (now-valid) cellPos before
computing contentStart/contentEnd. Reverse order means each replaceWith
only displaces positions that come later in the document — cells not yet
processed sit at lower positions and are unaffected.
Runtime-verified with a throwaway CJS node script against the repo's own
prosemirror-model + prosemirror-tables: 2×2 paste into variable-length
3×3 table passed; edge-clamped 3×3→(1,1) paste passed; forward-order
control test confirmed corruption.
* fix(editor): tighten TSV paste guard to tab-only; document header-row cut schema (BUG-1247)
TSV guard: require \t, not (\t OR \n). The previous guard admitted
newline-only text (code snippets, multi-line prose, addresses), causing
the grid-fill loop to fire and overwrite cells downward. Now only
clipboard text containing a tab character enters the TSV handler.
Accepted tradeoff documented in comment: our own single-column cut
produces tab-free output (one cell per line, no \t), so that cut result
won't round-trip via this handler — it lands as multi-line text in the
anchor cell. Spreadsheet single-column pastes are unaffected; they
carry text/html <table> and fall through at guard 2.
Header-row cut: deleteRow on row 0 is safe and intentional. The tiptap
Table extension schema is `table: { content: "tableRow+" }` and
`tableRow: { content: "(tableCell | tableHeader)*" }` — no mandatory
leading header row (verified at node_modules/@tiptap/extension-table/
src/table/table.ts:270 and src/row/table-row.ts:27). Removing row 0
yields a valid table; the current deleteRow path is correct. Comment
added recording the schema check so a future reviewer doesn't revisit.
Runtime-verified with verify-paste2.cjs (node script against repo's
own prosemirror-model + prosemirror-tables): newline-only paste falls
through (guard returns false, doc unchanged); tab-separated pastes
admitted correctly; old (\t OR \n) guard confirmed to have admitted
the code-snippet input.
* fix(editor): rectangular TSV for merged cells; paste dedup by physical pos (BUG-1247)
Serializer (copy/cut): replace forEachCell-based row accumulation with a
slot-by-slot walk of the selection rect via TableMap. For each (row,col)
slot, check whether it is the origin of its physical cell (cellRect.top
=== row && cellRect.left === col). Origin slots emit the cell's text;
covered slots (rowspan/colspan neighbours) emit an empty field. This
guarantees every TSV row has the same field count and columns align
correctly on paste. Previously, a colspan=2 cell in a 2-col selection
would emit one field for that row instead of two, misaligning all
subsequent columns.
Paste fill: add a Set<number> dedup keyed on physical cell offsets
(positionAt return values). Collect entries in forward (top-left →
bottom-right) visual order so the first encounter per physical position
is the origin slot's grid value — matching spreadsheet convention. Then
reverse for the transaction so each replaceWith only displaces positions
already processed. Without dedup, the merged cell was written once per
covered slot; with reverse order the last write (top-left) won but the
intermediate writes corrupted the document (verified: 'NEW00NEW01'
instead of 'NEW00' in the colspan=2 case).
Runtime-verified with verify-paste3.cjs against the repo's own
prosemirror-model + prosemirror-tables:
A) colspan=2 serialization → "A\t\nB\tC" (PASS)
B) rowspan=2 serialization → "A\tB\n\tC" (PASS)
C) colspan=2 paste dedup → origin gets top-left value, no double-write (PASS)
D) rowspan=2 paste dedup → origin gets top-left, covered slot skipped (PASS)
E) old code (no dedup) confirmed 'NEW00NEW01' corruption (PASS)
* fix(editor): use map.map[] instead of positionAt() to avoid covered-slot column shift (BUG-1247)
positionAt(row, col, table) SKIPS covered slots: for a visual slot
covered by a rowspan from above, it advances past the covered entry and
returns the next physical cell in that row. Example: 2-col table, (0,0)
has rowspan=2. positionAt(1,0) returns cell C (col 1) instead of cell A
(the owner). With the dedup Set in forward order, grid[1][0] (a col-0
value) is collected as the entry for C (col 1) — a column misalignment.
grid[1][1] is then dropped as a duplicate.
Fix: read the owning cell's table-relative offset via the raw map array
(map.map[row * map.width + col]) instead of positionAt(). The raw array
stores the owning cell's offset for EVERY visual slot, including covered
ones — identical to what the serializer's slot-by-slot walk uses. With
this change, covered slots resolve to their owning cell's offset; the
dedup Set drops the covered-slot grid value; the next non-covered slot
in the same row resolves to a distinct physical cell and receives the
correct column-aligned value from the grid.
Verified correct semantics with verify-paste4.cjs (16 assertions):
A) colspan=2 serialization rectangular — PASS
B) rowspan=2 serialization rectangular — PASS
C) colspan=2 paste dedup — PASS (regression)
D_new) rowspan=2 paste: A←R0C0, B←R0C1, C←R1C1 (R1C0 dropped) — PASS
D_bug) positionAt() confirmed: C wrongly gets R1C0 — CONFIRMED
F) colspan=2 covered-right: A←R0C0, R0C1 dropped, P←R0C2 — PASS
* fix(editor): origin-check guards covered-slot writes outside paste rect (BUG-1247)
map.map[] resolves covered slots to the owning cell's offset even when
that cell's origin lies outside the paste rectangle. Without an origin-
check, a covered slot whose owner lives above or left of the anchor would
silently overwrite a cell the user never selected.
Fix: after computing the slot's owning-cell offset via map.map[], call
map.findCell(cellOffset) and compare the owner's rect.top/rect.left to
the current targetRow/targetCol. If they differ, this is a covered slot
(or a partial-intersection slot) — skip it. This is structurally correct
for both cases: intra-rect covered slots (origin inside rect, duplicate
offset) and partial-intersection slots (origin outside rect, distinct
offset the dedup Set would never have caught).
The dedup Set is kept as a cheap invariant guard — an origin slot can
now only recur via a malformed cell with colspan > table width, which is
impossible in a valid ProseMirror document. The Set documents intent and
protects future edits.
Verified with verify-paste5.cjs (26 assertions — full regression A-F
plus new cases G and H):
G) rowspan origin ABOVE anchor: A untouched, C gets NEW01 — PASS
(buggy version confirmed: A clobbered with NEW00)
H) colspan origin LEFT of anchor: A untouched, P gets R0C1 — PASS
(buggy version confirmed: A clobbered with R0C0)
|
||
|
|
b4e9e200c9 |
fix(web): collapse WorkspaceSheet switcher list when the sheet closes (IDEA-1720) (#711)
The inline workspace-switcher list state lives in WorkspaceSheet, which stays mounted while DockedSheet's children unmount — so an expanded list survived close/reopen of the mobile Workspace tab. Replace the plain $state with a reassignable $derived keyed on `open`: the card toggle still works by reassignment, and the list snaps back to collapsed on any open-state change, including backdrop/swipe dismissal that no handler in this component observes. |
||
|
|
35cc26daaf |
fix(web): collection cards show real child-item progress; child-progress endpoint (BUG-1509) (#710)
* BUG-1509: show real child-item progress on non-plan collection cards
Backend: extract collectionChildrenProgress helper from handlePlansProgress
and expose it at GET /collections/{collSlug}/child-progress with identical
visibility/guest-grant filtering. handlePlansProgress refactored to delegate
to the shared helper (no duplication). Route registered in the existing
/{collSlug} subrouter alongside checkbox-progress.
Frontend: +page.svelte fetches child-progress + checkbox-progress in parallel
for non-plans collections; per-item merge prefers child-progress (label
"tasks") when total>0, falls back to checkbox counts (label "done"). ItemCard
extended to render progress.label when present. ChildItems.svelte render gate
fixed to include error state so a failed /children fetch surfaces instead of
silently vanishing.
Tests: TestCollectionChildProgress covers happy path (linked children counted
correctly), zero-children items (present with total=0), 404 for unknown
collection, and restricted-member visibility gate (empty response for hidden
collection, not a data leak).
* fix: include_archived on child-progress and progressLabel desync (codex r2)
P1: GetAllItemProgress now accepts includeArchived bool; the parent-row
filter (AND p.deleted_at IS NULL) is conditioned on it, mirroring
CollectionCheckboxProgress. handleCollectionChildrenProgress reads
?include_archived=true and threads it through. handlePlansProgress
hardcodes false — no contract change there. collectionChildProgress()
client method gains opts?: { includeArchived? } with qs() serialisation.
Both call sites in +page.svelte (loadCollection and refreshProgress) now
pass includeArchived to the child-progress fetch.
P2: refreshProgress plans branch now sets progressLabel = 'tasks' so a
sync-triggered refresh after a failed initial plans load renders with the
correct label. progressLabel = 'done' moved inside the non-plans try block
(symmetric with plans) so a thrown fetch leaves the label in whatever
state the previous collection set, not silently desync'd.
Tests: TestCollectionChildProgress extended — archives parentA, confirms
it drops from default response and reappears with include_archived=true.
* fix: thread includeArchived through childrenDoneFiltersForCollection (codex r3)
GetAllItemProgress conditionally drops the p.deleted_at IS NULL parent
filter when includeArchived=true, but the filter-discovery call at the
top of the function — childrenDoneFiltersForCollection — still had the
filter hardcoded. If a child collection's only parent links pointed to
archived parents, that collection was absent from the done-semantics map,
and those children fell back to default status terminals rather than the
collection's configured done field — producing wrong done counts.
Fix: childrenDoneFiltersForCollection gains an includeArchived bool param;
the JOIN on items p conditions p.deleted_at IS NULL on it, exactly mirroring
the main query. GetAllItemProgress passes includeArchived through. The only
other caller of this helper (GetItemProgress via childrenDoneFiltersForParent)
is unaffected — that path is a separate function and never surfaces archived
parents.
Test: TestCollectionChildProgress extended with a "Widgets" collection whose
done field is `state` (terminal: "shipped") — not the default `status` field.
An archived task parent links two widget children (one shipped, one open); no
live task parent links into widgets, so the filter-discovery bug would drop
the collection from the map and produce done=0. The test asserts done=1 and
was verified to fail on the pre-fix code.
|
||
|
|
eb5b2bf5de |
fix(web): comment wikilinks resolve refs + carry username prefix (BUG-1744) (#709)
renderMarkdown's plain [[body]] branch only matched exact item titles,
so [[REF]] links in comments rendered as broken (orange strikethrough)
while item content resolved them fine via wikiLinksToMarkdown's
ref-first lookup. Extract the shared resolveWikiBody() — ref-first,
then legacy title (full-body pipe form, case-insensitive key,
collection/Title) — and use it from both renderers so they can't
drift again. Also aligns renderMarkdown's [[...]] capture with the
escape-aware regex.
Verification surfaced a second latent bug: the item page never passed
username into ItemTimeline, so resolved comment links pointed at
/{ws}/... instead of /{username}/{ws}/... and landed on a dead route.
Plumb username through ItemTimeline → TimelineCommentCard.
Reported in GH #697.
|
||
|
|
0234239547 |
feat(web): graph node labels — always-on for hubs, distance-faded elsewhere (TASK-1743) (#708)
Camera-facing ref labels (three-spritetext, dynamic-imported alongside the renderer so it stays out of the entry bundle) make the graph readable without tap/hover. Anti-soup rules: hubs (child_count > 0) always labeled; in focus mode the selected node + neighborhood + blocker chain stay labeled while dimmed nodes hide theirs; everything else fades in between 160 and 120 camera units. The visibility walk is imperative (throttled orbit-controls 'change' listener + repaint lockstep + payload commits) and only toggles sprite.visible/opacity — nodeThreeObject is set once and never re-assigned, preserving the BUG-1742 no-flush discipline. Label offset clears the actual sphere radius (4·∛val + pad) so big hub labels don't render inside the node. Parent: IDEA-1740 (graph follow-ups). |
||
|
|
38f18ac534 |
fix(web): graph repaints in place instead of full-scene refresh (BUG-1742) (#707)
graph.refresh() sets three-forcegraph's _flushObjects flag, which destroys and recreates every Three.js object in the scene. Desktop GPUs hide the rebuild inside a frame; on mobile it reads as a full-screen flash on every select, deselect (background tap), SSE pulse, and 2s fade tick. Replace all refresh() call sites with repaint(): re-assigning fresh closures for the selection/pulse/chain-dependent accessors (nodeColor, linkColor, particle trio) takes the lib's in-place material/particle update path — objects survive, no flash. linkWidth is the one visual accessor whose prop-change DOES flush link objects (cylinder geometry is in the clear list), so width is now static per edge type and chain emphasis rides on full-alpha red + particles, as it already did visually. Also kill the gray mobile tap-highlight on the canvas. Reported by Dave on mobile right after PLAN-1730 shipped. |
||
|
|
30bdbeb967 |
feat(web): graph layout tuning — gravity wells + terminal recede (TASK-1738) (#706)
Per-link-type force tuning turns the undifferentiated force soup into structure: parent/implements links pull short and strong (children orbit their parent), blocks links keep medium tension, wiki-link / related stay long and weak so associative filaments don't collapse clusters into each other. Charge repulsion scales with subtree mass (clamped at 10 children) so big plans carve out space. Accessors are applied once at construction — d3-force-3d re-runs them on every graphData swap (links()/nodes() re-init), so payload/filter changes re-settle automatically with the lib's built-in re-heat. Terminal nodes (visible with show-completed on) recede: 0.4× size and collection color faded toward the backdrop — burned-down work reads as embers. Precedence: chain > pulse > dim > terminal; a terminal node on a blocker chain still burns full red. Parent: PLAN-1730. Closes the plan's task list. |
||
|
|
2904802486 |
feat(web): blocker-chain tracing as lit path + edge styling by type (TASK-1737) (#705)
Selecting a node now answers "why can't this start?" visually: a cycle-safe BFS walks the transitive blocker chain upstream over 'blocks' edges and burns it bright red — chain nodes mix toward the blocks red and are never dimmed, chain edges go full-alpha wide with directional particles flowing blocker→blocked into the selected node. Precedence: chain > pulse > dim for nodes, chain > adjacency > dim for edges. The chain recomputes when a surviving selection's payload or filters change, and clears on deselect. DetailCard grows a red-accented "Blocked by" list (clickable rows fly to the blocker and re-trace its chain; capped at 6 with +N more, chain depth noted when deeper) and a "Blocks N items" stat. supersedes / split-from edges drop to 0.6 alpha to round out per-type styling. Parent: PLAN-1730. |
||
|
|
1bd3e52230 |
feat(web): graph SSE live layer — glow/pulse on touched nodes (TASK-1736) (#704)
* feat(web): graph SSE live layer — glow/pulse on touched nodes (TASK-1736) The graph now feels alive while agents work: item events from the workspace SSE stream flash the touched node toward white and fade it back over 45s (a lazy 2s prune interval animates the decay and stops itself when idle). Structural events (created/archived/restored) and item_updated fold into one trailing-debounced refetch (1.5s) through the existing loadGraph stale-token path; comment_created is glow-only. New items arrive glowing via a pending-uuid stash resolved when the refetch lands. Pulse composes before focus-mode dimming so touched nodes still flicker subtly in the dimmed crowd. Selection clears when the selected item leaves the payload (archived under focus mode). Events correlate via a uuid→ref bridge rebuilt per payload — the graph endpoint now emits each node's item UUID alongside the ref. Parent: PLAN-1730. * fix(web): refetch graph on sync_required per Codex review (round 1) items_bulk_updated and replay-buffer gaps route through onSyncRequired, not onItemEvent — the graph stayed stale after bulk archive/move/assign until the next single-item event. Fold both into the existing debounced refetch. |
||
|
|
77dcd07ecd |
feat(web): graph search fly-to + collection/status/role filters (TASK-1735) (#703)
* feat(web): graph search fly-to + collection/status/role filters (TASK-1735) Toolbar grows a type-ahead search (ref/title over the post-filter node list; ArrowUp/Down + Enter picks, Escape closes without stealing the page's deselect) that routes through the existing selectNode() — same camera fly-to, highlight, and detail card as a click. Client-side filters subset the rendered graph: collection chips with palette dots, status chips, and a role select (hidden when no node carries a role; the graph endpoint now emits the assigned agent-role slug per node). Edges survive only when both endpoints do; counts read "X of Y" while filtered. Workspace switch resets filters; show-completed doesn't. Filter changes deselect so a vanished node can't strand focus mode. New GraphToolbar.svelte owns the presentational toolbar; the page owns authoritative filter state (CONVE-1688 discipline unchanged). Parent: PLAN-1730. * fix(web): close graph search dropdown on blur per Codex review (round 1) The dropdown opened on focus/input but only closed on pick or Escape, leaving stale results floating over the canvas after clicking away. The result buttons already pick on mousedown+preventDefault, so the input never blurs mid-pick — a plain onblur close is safe. * fix(web): gate search Escape on dropdown visibility per Codex review (round 2) Escape in a focused-but-empty search now falls through to the page-level deselect instead of being swallowed by the searchOpen flag. |
||
|
|
1c3db435a3 |
feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) (#702)
* feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) Clicking a node now enters focus mode instead of navigating away: the camera flies to the node (800ms, standard distance-ratio pattern with at-origin guard), its neighborhood (any shared edge type) keeps full color while everything else dims to low-alpha, adjacent links brighten and the rest fade hard. A DetailCard slides in from the right: collection dot + ref + title, status pill (terminal styling), child count, relative updated-at, plus priority/assignee fetched lazily via the items API (stale-select token), and the "Open item" button carrying the old click-through navigation. Deselect via background click, Escape (only when a selection is active), or automatically when the workspace/show-completed payload changes. Selection sets stay plain non-reactive lets per CONVE-1688; accessor re-evaluation is explicit via graph.refresh(). Parent: PLAN-1730. * fix(web): focus-mode link adjacency vs mutated endpoints per Codex review (round 1) The force layout mutates link source/target from ref strings into node objects after ingest, so linkColor's adjacency check against selectedRef silently failed once the simulation ran. Preserve the raw refs as sourceRef/targetRef at mapping time and compare those. |
||
|
|
db3917f6d2 |
feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733) (#701)
* feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733)
New full-viewport graph page at /{username}/{workspace}/graph rendering
the TASK-1731 endpoint via 3d-force-graph. Three.js loads only through
a dynamic import inside onMount, landing in its own ~1.3MB chunk
referenced solely by the graph route node — entry bundle unchanged.
Node color = collection (local hex palette; the chart PALETTE's CSS
vars can't reach WebGL), node size = 1 + 2×child_count, blocks edges
red with directional arrows, structural links brighter than soft ones.
Click navigates to the item page (ResolveItem accepts refs in the slug
param). Active items by default with a "Show completed" toggle that
refetches in place; workspace switches refetch with a stale-response
guard. Teardown via _destructor + ResizeObserver disconnect.
Nav: 'graph' added to destinations.ts (NavKey, RESERVED_SLUGS, primary
destinations, getActiveKey) and a Sidebar entry after Insights — the
mobile More sheet picks it up from the shared source automatically.
Parent: PLAN-1730.
* fix: clear stale canvas on workspace switch + reserve 'graph' collection slug per Codex review (round 1)
1. The renderer-sync effect now pushes empty graphData when the
reactive payload is null (workspace switch in flight / load error)
instead of early-returning — the previous workspace's nodes no
longer linger behind the loading overlay.
2. 'graph' added to reservedCollectionSlugs so a collection can't
shadow the /{username}/{workspace}/graph route, matching the
frontend's RESERVED_SLUGS.
|
||
|
|
a20095e870 |
feat(web): graph TS types + API client method (TASK-1732) (#700)
GraphNode / GraphEdge / GraphResponse types mirroring the
GET /workspaces/{ws}/graph payload (TASK-1731), and
apiClient.graph.get(ws, includeTerminal) to fetch it.
Deliberately web-only: no CLI command, no MCP action — the graph
payload is a rendering data feed for the upcoming /graph route, not
an agent surface.
Parent: PLAN-1730.
|
||
|
|
299119403c |
test(e2e): blog screenshot capture for BLOG-1704 (pad v0.7) (#696)
Adds a PAD_BLOG_SCREENSHOTS-gated describe block that seeds a tasks collection, creates a public collection share link, and captures the /s/<token> board view — the source for the v0.7 post's cover/og. |
||
|
|
5c491b78b5 |
feat(web): mobile bottom navigation (PLAN-1694) (#692)
* feat(web): mobile bottom navigation + quick-capture (PLAN-1694)
Add a mobile-only persistent bottom navigation bar that coexists with the
existing TopBar. Five slots: Dashboard, Search, Quick-capture (center +),
Activity, More. The "More" slot opens a BottomSheet with the sidebar's
overflow destinations + collections; the center action opens a quick-capture
sheet that creates an item via the existing API.
- New shared nav source (lib/nav/destinations.ts) consumed by both the
desktop Sidebar and the new BottomNav/More sheet so the two can't drift;
Sidebar refactored to derive active-state from it (lossless).
- BottomNav mounts in the workspace layout (workspace-only), shows only on
mobile (uiStore.isMobile, ≤768px), and toggles body.has-bottom-nav so
app.css reflows .main-content above the fixed bar.
- iOS: app.html viewport-fit=cover + env(safe-area-inset-bottom) padding.
- z-index 40 (above TopBar/sidebar, below sheets).
Header retirement + full "You" (workspace/account) consolidation are
deliberately deferred to a human design checkpoint (DR-3).
Verify: web npm run check (0 errors) + npm run build (✓). Codex review loop
clean (1 P2 fixed: quick-capture re-defaults a stale collection slug).
Refs PLAN-1694 (TASK-1695, TASK-1696, TASK-1697, TASK-1698); parent ROADM-28.
* feat(web): retire mobile header in-workspace, add You sheet + contextual bar (PLAN-1694)
Complete the mobile nav vision on top of the bottom bar: inside a workspace
the global mobile header is gone, replaced by the BottomNav + a "You" sheet +
a contextual back/title bar on detail screens.
- BottomNav 5th slot More → You (👤). New YouSheet composes the workspace
switcher, the nav overflow, and the full account menu carried over from the
retired TopBar (Workspaces/Settings/Billing/Admin/theme/Resources/Connect/
Sign out) so nothing is lost.
- Root layout gates <TopBar mobile /> on !inWorkspace (page.params.workspace),
keeping it for the non-workspace picker/home which has no BottomNav; the
.app-layout top offset now applies only via body.has-mobile-topbar.
- New MobileContextBar: fixed back+title bar shown only on detail screens
(path depth ≥2); body.has-context-bar reflows .main-content top. Root/tab
screens render full-height (reclaimed space).
Codex consensus + review clean (no P1). P2s fixed: safe in-app-history check
via afterNavigate (deep-link fallback to parent URL), humanized title fallback
for pages that don't wire titleStore, and the You sheet closes on navigation
so a workspace switch dismisses it.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1699, TASK-1700); parent ROADM-28.
* feat(web): redesign mobile nav into Workspace + You docked sheets (PLAN-1694)
Address design feedback: the previous "You" sheet reused common/BottomSheet
(covered the nav bar) and embedded the old WorkspaceSwitcher (plain, unadapted).
Replace it with two purpose-built surfaces that dock ABOVE the nav.
- DockedSheet (new): bottom sheet anchored above the bottom nav — backdrop
stops at the nav's top edge so the bar stays visible + tappable and the
active slot stays lit. ~2/3 height, grab handle, slide-up, swipe-down /
tap-out / Escape to dismiss. No more full-screen overlay covering the nav.
- Bottom-nav slot 1 Dashboard → Workspace (icon = current workspace avatar),
opening WorkspaceSheet: a designed switcher card (inline-expand list) +
Navigate tile grid + Collections.
- "You" slot → YouSheet, now account-only: profile header, theme toggle,
account Settings / Workspaces / Billing / Admin / Connect / Resources /
Sign out. Search / + / Activity unchanged.
- New avatar util (avatarColor/avatarInitial) for workspace + user avatars.
Splits the overstuffed single sheet into two focused surfaces (where-am-I vs
me) and removes the reused WorkspaceSwitcher/BottomSheet. Codex review CLEAN.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
* feat(web): toggle Workspace/You sheets on repeat nav tap (PLAN-1694)
Tapping the Workspace or You nav slot a second time now closes its open
sheet (tap to open, tap again to close). Opening one still closes the other.
The docked backdrop stops above the nav, so the slot button stays tappable
while its sheet is open.
Refs PLAN-1694 (TASK-1701).
* feat(web): dock mobile search to match the nav sheets (PLAN-1694)
The CommandPalette was the last mobile surface that didn't match — a
full-screen takeover (square, no handle, hid the nav). Give its mobile
presentation the same docked treatment as the Workspace/You sheets;
desktop ⌘K is untouched.
- Mobile: dock the palette above the bottom nav (backdrop stops at the nav's
top edge so the bar stays visible), rounded top, grab handle with
swipe-down-to-dismiss, tap-outside to close, no X. Lift above the nav only
when it's present (:global(body.has-bottom-nav)); flush to bottom on the
non-workspace picker. Keyboard handling preserved: input stays at the sheet
top, .results is the sole scroll area, min-height keeps the input clear of
the keyboard when empty.
- BottomNav: Search slot toggles (tap to open, tap again to close) and lights
up while open; the three surfaces (Workspace / You / Search) are now
mutually exclusive.
Codex review CLEAN. Verify: web npm run check (0 errors) + build (✓).
Note: on-screen-keyboard feel should get an on-device smoke test.
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
|
||
|
|
72d8963c4c |
fix(timeline): resolve collab-snapshot diffs + collapse autosave bursts (BUG-1612) (#691)
Item timelines showed two collab-snapshot problems:
1. Artifacts: the timeline endpoint (ListItemVersionsBeforeTime) served
diff versions unresolved, so TimelineVersionCard fed raw diff-match-patch
patch text into DiffView. Add GET /items/{slug}/versions/{versionID}
(handleGetItemVersion -> Store.GetItemVersionResolved) and have the card
lazily fetch resolved content the first time a diff version is expanded.
2. Clutter: every ~5s web-editor autosave flushes a collab-snapshot version.
buildTimeline now collapses uninterrupted collab-snapshot bursts (within
10 min, no intervening event) to their newest entry, and the source badge
renders as "Autosave" instead of the raw slug.
Adds TestCollapseAutosaveBursts. Known limitation (accepted): collapse is
page-local, so a 150+ cross-actor autosave chain can leak one row per
"Load more" page — gated by the 1h version throttle, degrades gracefully.
|
||
|
|
d2e80efefc |
fix(web): reflow page content when sidebar collapses (BUG-1651) (#689)
Desktop .sidebar.collapsed only applied translateX(-100%), which slides the sidebar off-screen but keeps its column reserved in the .app-shell flex row — .main-content (flex: 1) had nothing to expand into, leaving a blank gap. Release width/min-width on desktop-collapsed so the flex row reflows; scoped :not(.mobile) since the mobile sidebar is position: fixed and uses its width to drive the slide-in drawer. Width animates alongside the existing transform transition for a smooth close. |
||
|
|
a5c7fc986e |
fix(attachments): grant-aware upload auth so share-link editors can attach (BUG-1661) (#688)
handleUploadAttachment gated on requireMinRole("editor") — a workspace-level
check — but the editor and comment composer offer the paste/drop upload
affordance based on grant-aware edit permission. A grant-based editor (guest
with an item/collection edit grant via a share link, no workspace editor role)
could type/post but hit 403 on upload.
Server: read ?item_id early (before spooling the body); when present and
resolvable, authorize via requireEditPermission against the item's grant chain,
else fall back to requireMinRole("editor") for free-floating uploads (new-item
creation, storage settings). Reordered the nil/getWorkspaceID checks above auth.
Client: upload() now also sends item_id as a query param so the server can
authorize before spooling. Threaded the item UUID through Editor.svelte (both
mount sites) and CommentEditor.svelte (ItemTimeline composer + the 3
TimelineCommentCard composers via comment.item_id).
Test: TestUpload_GrantBasedEditorCanAttach — guest with an item edit grant gets
201 with ?item_id and 403 without it (confirms the editor-role fallback didn't
widen access).
|
||
|
|
034b540a77 |
fix(web): de-reactify open-transition trackers in 3 more modals/banners (#687)
Sweep follow-up to BUG-1687. ConnectBanner, CreateCollectionModal, and ConnectWorkspaceModal each tracked their open→close transition with a `$state` variable (`prevOpen`/`lastOpen`) that their `$effect`/`$effect.pre` both read and wrote — the same self-invalidating pattern that silently wedges Svelte's effect scheduler in production builds on close. Make each tracker a plain `let` (they're only used for edge-detection inside the effect), so the effects depend solely on `open`/`connectOpen` and never re-trigger themselves. Found via a codebase-wide audit; these were the only other genuine matches. |
||
|
|
a4e0eb13d8 |
fix(share): stop ShareDialog effect from wedging the scheduler in prod (#686)
ShareDialog tracked the open transition with `let prevOpen = $state(false)` and an `$effect.pre` that both read and wrote `prevOpen` — a self-invalidating effect. In dev this trips `effect_update_depth_exceeded`; in a production build the guard differs and it silently wedges Svelte's global effect scheduler the moment `open` flips on close. Result: after opening+closing the share dialog, effects stop flushing app-wide — clicks change the URL but the view never re-renders (no error, no CPU spin). Make `prevOpen` a plain variable (it's only used for edge-detection inside the effect), so the effect depends solely on `open` and never re-triggers itself. |
||
|
|
d29392443a |
feat(share): inline read-only row expand in public shared view (TASK-1684) (#685)
* feat(share): inline read-only row expand in public shared view (TASK-1684)
Clicking an item row/card in the public `/s/[token]` collection view now
expands the item's fields + sanitized markdown content INLINE on the same
page — no navigation. Items aren't individually shared, so a link would 404
or bypass item-level share ACLs; the expansion is gated by the same share
link and is strictly read-only.
- New PublicItemExpansion panel renders fields + content; it does NOT
sanitize — it receives pre-sanitized HTML from the route's single
marked()+DOMPurify pipeline (the same one the single-item share view uses),
so there's no new {@html} XSS surface.
- Wired `expandable`/`onactivate`/`expandedKey`/`renderContent` through
PublicCollectionView → board/list/table renderers. Toggle on re-click.
- Keyboard accessible: Enter/Space toggles, aria-expanded + aria-controls on
each interactive row/card.
- Works across all three views: board card, list row, and table row (the
table panel spans the full grid width as a presentation row).
- Per-item sanitized HTML is memoized; expansion collapses on view switch.
Parent: PLAN-1677.
* fix(share): render literal field values verbatim, label-format only categorical fields per Codex review (round 1)
formatLabel() was title-casing/de-hyphenating every expanded field value,
corrupting literals like dates (2026-05-31), slugs, IDs, and URLs. Now only
status/priority/select values get label-formatting + color; all other fields
render verbatim, matching the single-item share view.
|
||
|
|
b422988601 |
feat(share): schema-driven colors + empty/large-collection states (TASK-1683) (#684)
Phase 3 polish for the public shared collection view: - Schema-driven status/select colors via fieldValueColor(): canonical open/in_progress/done/blocked palette matches the owner exactly, and a custom terminal option (schema terminal_options, e.g. shipped/closed) now reads as "done" green in cards, list rows, table cells, and board column accents — the bare literal switch missed these. Table colors any select-type column, not just status/priority. - Polished empty state (icon + title + hint, dashed card) shared across all three view types. - Large-collection cap: PUBLIC_ITEM_CAP=200 with a visible "showing N of M" banner (no silent truncation). Applied after saved-view filtering so the count reflects the visible set. - Documented the saved-view sort deferral: payload carries no sort_order/timestamps, so list_sort_by stays a no-op (owner's sort_order order is already honored); a faithful field sort is a backend/payload decision. Parent: PLAN-1677. |
||
|
|
d4ff473715 |
feat(share): read-only view switcher on shared collection route (TASK-1682) (#683)
* feat(share): read-only view switcher on shared collection route (TASK-1682) Add a read-only Board/List/Table view switcher to /s/[token]. Anonymous viewers can toggle the presentation of a shared collection; the choice drives PublicCollectionView's `view` prop, overriding the owner's settings.default_view (which is the initial selection, falling back to 'list'). Saved views from the share payload (collection.views) surface as selectable chips alongside the three base types; selecting one resolves through its view_type (kanban -> board) to a base renderer. Purely presentational — no mutation affordances. Selection persists via the ?view= URL param + localStorage keyed by share token, mirroring the logged-in collection page's saveViewMode/loadSavedViewMode pattern (read-only context, so no server save). URL precedence: ?view= > localStorage > owner default. Parent: PLAN-1677. * fix(share): apply saved-view config (grouping + filters) per Codex review (round 1) Saved-view chips previously only pinned the base view_type, ignoring the view's config — so a public saved view could show the whole collection in the wrong grouping. Now overlay the saved view's config onto the rendered output (read-only): config.group_by maps to board_group_by/list_group_by per active base type, and config.filters (eq/in) narrow the item set. Render path feeds derived parsedCollection/parsedItems to PublicCollectionView. Parent: PLAN-1677. * fix(share): graceful filter skip + correct URL persistence per Codex review (round 2) - Saved-view filters now skip fields absent from the public payload (share items carry only `fields`, not top-level tags/parent/phase), so a saved view filtering on tags no longer hides the entire collection; it degrades to showing the superset. - syncUrl drops ?view= only when the base selection equals the owner default (not hard-coded 'list'), so picking List on a board-default share produces a link that reproduces the choice. Parent: PLAN-1677. * fix(share): schema-based filter evaluability + URL normalization per Codex review (round 3) - Decide saved-view filter applicability from the public collection schema, not item values: a filter on a schema field stays applied even when no shared item sets it (returns none, matching the owner). Only fields genuinely absent from the public schema (tags/parent/phase) are skipped. - initSelection normalizes the URL after resolving, so a stale/invalid ?view= (e.g. deleted saved view) no longer leaves the address bar describing a view the page isn't rendering. Parent: PLAN-1677. |
||
|
|
be53856223 |
feat(share): include saved views in collection share payload (TASK-1681) (#682)
* feat(share): include saved views in collection share payload (TASK-1681)
Expose the collection's saved views on the public /s/{token} payload so the
read-only view switcher (TASK-1682) can render and toggle them. Fetched via
Store.ListViews (ordered by sort_order) and projected to a public shape
under collection.views — name, slug, view_type, config (parsed object),
is_default, sort_order — with internal UUIDs and timestamps stripped.
Always emits an array (never null); empty when the collection has no saved
views, so the switcher falls back to settings.default_view.
Extends the SharePayload TS type with PublicShareView + an optional
collection.views array (additive) for TASK-1682 to consume.
Parent: PLAN-1677.
* fix(share): pin distinct view sort_order in test per Codex review (round 1)
CreateView inserts sort_order=0 and now() is second-granularity, so the two
test views could tie on (sort_order, created_at) and SQL could return either
order, flaking the position-based assertion. Set explicit sort_order 0/1 and
assert on it.
Parent: PLAN-1677.
|
||
|
|
ae055ab412 |
feat(share): render matching view on /s/[token] (TASK-1680) (#681)
Wire PublicCollectionView into the public share page so a shared
collection renders the owner's chosen view type (board/list/table,
falling back to list) instead of a hardcoded flat list. The raw
`collection` + `items` branches of the share payload are passed
straight through — the component parses settings/schema/fields
defensively. expandable={false} for now (inline expand is TASK-1684).
Add a typed SharePayload (+ PublicShareCollection/Item/Settings) to
$lib/types and annotate api.share.get's return type to reflect the
enriched payload (collection.settings, collection.schema,
items[].content).
Existing share-page chrome (header/footer/password/auth states) is
untouched; only the collection BODY rendering swaps. Dead collection
row/header CSS removed.
Parent: PLAN-1677.
|
||
|
|
a58c64fb07 |
feat(web): public read-only collection view renderers (TASK-1679) (#679)
* feat(web): public read-only collection view renderers (TASK-1679)
Adds purpose-built read-only renderers for the public share page that
match the owner's view TYPE (kanban/list/table) and grouping, styled for
an anonymous external audience — no app chrome, no edit/drag/create
affordances, no internal app links.
New components under web/src/lib/components/share/:
- shareView.ts — payload types + DEFENSIVE parsers (settings/schema/
fields tolerated as JSON string OR object, since TASK-1678 ships in
parallel) + label/status/priority color + grouping helpers lifted
from the in-app vocabularies (ItemCard / BoardView / ListView).
- PublicItemCard.svelte — inert read-only card (board).
- PublicBoardView / PublicListView / PublicTableView — the three view
renderers; board groups by board_group_by|status, list optionally
groups by list_group_by, table mirrors TableView's CSS-grid layout.
- PublicCollectionView.svelte — entry point that parses the raw payload
and switches on settings.default_view, delegating to a leaf renderer.
Why: the share page currently renders a hardcoded flat list, ignoring
the owner's chosen view. These components are the render layer; wiring
into /s/[token] against the merged payload is TASK-1680. Row/card
components carry an optional expandable + onactivate contract so the
inline read-only expand (TASK-1684) can be added without a rewrite.
Not wired into any route yet; npm run build passes.
Parent: PLAN-1677.
* fix(web): stable unique each-keys for public renderers per Codex review (round 1)
PublicItem now carries a `key` assigned at parse time from the item's
payload position (ref-prefixed when present). The board/list/table
renderers key their {#each} blocks on it instead of `ref || title` —
refs may be empty and titles aren't unique, so the old key could collide
for two same-titled unprefixed items and break rendering/state.
Parent: PLAN-1677.
|
||
|
|
634e834f22 |
feat(board): Trello-style inline draft card creation (TASK-1676) (#678)
* feat(board): Trello-style inline draft card creation (TASK-1676) Replace the lane `+` direct-navigate (TASK-1671) with an inline draft card. Clicking `+` (or "Add item here") opens an editable card in the lane — no item exists yet. Enter creates it (group value pre-filled) and opens it; blur keeps the card without saving; Escape hides it but retains the text so reopening restores it (until reload). Drafts are per-lane and in-memory. A beforeNavigate guard intercepts in-app navigation while any lane has unsaved draft text and shows a Save / Discard / Stay dialog: Save creates all pending drafts (in place, no navigate) then proceeds, Discard drops them, Stay cancels. Reload/tab-close aren't guarded (nav.to is null) — drafts intentionally live only until reload. quickCreateInColumn now takes (groupValue, title, navigate) and returns the created item / throws so the draft can be restored on failure. The draft card renders above the dndzone so it isn't draggable. * fix(board): lift draft state to page + clear-each-on-save per Codex review (round 1) (1) BoardView unmounts on a board↔list view switch, which destroyed unsaved inline drafts and bypassed the leave guard. Move the draft state (draftText/draftOpen) to the page and bind it into BoardView, and move the beforeNavigate guard + Save/Discard/Stay dialog to the page (always mounted). A draft now survives view switches (state persists; the card just isn't rendered off-board) and the guard fires regardless of view. (2) leaveSaveAll cleared all drafts only after the whole loop, so a retry after a partial failure re-created already-saved drafts. Clear each draft as its create succeeds. * fix(board): only guard true leaves, not same-page URL syncs per Codex review (round 2) beforeNavigate fired on every goto while a draft existed — including the replaceState query/view-state syncs from updateUrlFilters (view toggle, filters, search). Switching Board→List or tweaking a filter wrongly opened the Save/Discard dialog. Skip navigations whose destination pathname equals the current one (internal same-page sync); the draft survives those as page state, so only true page leaves are guarded. * fix(board): don't guard willUnload navigations per Codex review (round 3) For native/external navigations (willUnload), goto can't replay the leave after cancel, so Save/Discard could strand the user. Skip guarding willUnload (and full-unload) navigations — treated like reload, drafts intentionally lost — so the replay path is always a client-side goto. * fix(board): replay Back/Forward with history.go to preserve history per Codex review (round 4) Cancelling a popstate (Back/Forward) navigation then replaying via goto(url) pushed the target as a new history entry, corrupting Back. Capture nav.type/nav.delta and replay popstate with history.go(delta); non-popstate leaves still replay with goto. |
||
|
|
710c76fb66 |
feat(board): per-lane sort override in the kebab menu (TASK-1673) (#677)
Add a "Sort lane by" drill-down to LaneActionsMenu — an ephemeral per-lane override on top of the page-wide sort. BoardView holds the override map (Record<lane, SortMode>, not persisted), applies the effective mode (override ?? page default) per lane in propColumnData, and disables drag per-lane under a non-manual effective sort. The submenu lists the same options as the toolbar (Priority hidden when no priority field) plus a "Page default" entry to clear the override, with a check on the active one. Sort is a view preference, available to everyone — so the kebab now shows for any non-empty lane (not just editors), and the menu's separators are section-gated so nothing dangles when a viewer sees only Sort. |