From ccd1c973836a6472cf47d5da1593a36652a83cfc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 18:45:27 +0000 Subject: [PATCH 1/4] fix(web): port homepage UI tweaks from platform #7421 (LUM-1726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift port of vellum-assistant-platform#7421 (commit f216413). - Suggestion pill icon size: 9px → 18px - Hide feed filter bar when only one category is present (<=1) - Filter bar → feed sections spacing: lg → sm (8px) - Unread indicator dot: right → left of feed row icon - Detail panel default: defaultLeftWidth={600} → defaultLeftPercent={50} resolved via useLayoutEffect to prevent flash from pixel fallback - Derive effectiveFilter from activeFilter + presentCategories so a stale category filter automatically falls back to "All" instead of stranding the feed in an empty state with no visible filter controls Adds defaultLeftPercent prop to ResizablePanel (design library) with useLayoutEffect-based resolution that respects existing localStorage preferences. https://claude.ai/code/session_013dBXRbLF218UhdLq7FEAvv --- .../src/domains/home/home-feed-filter-bar.tsx | 2 +- apps/web/src/domains/home/home-feed-list.tsx | 12 ++++++---- apps/web/src/domains/home/home-page.tsx | 2 +- apps/web/src/domains/home/home-recap-row.tsx | 2 +- .../domains/home/home-suggestion-pill-bar.tsx | 2 +- .../src/components/resizable-panel.tsx | 22 +++++++++++++++++++ 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/web/src/domains/home/home-feed-filter-bar.tsx b/apps/web/src/domains/home/home-feed-filter-bar.tsx index 9ae97f74caa..158984b19d6 100644 --- a/apps/web/src/domains/home/home-feed-filter-bar.tsx +++ b/apps/web/src/domains/home/home-feed-filter-bar.tsx @@ -111,7 +111,7 @@ export function HomeFeedFilterBar({ categories.includes(c), ); - if (presentCategories.length === 0) return null; + if (presentCategories.length <= 1) return null; return (
diff --git a/apps/web/src/domains/home/home-feed-list.tsx b/apps/web/src/domains/home/home-feed-list.tsx index 1f81d51dc07..55b2bef57df 100644 --- a/apps/web/src/domains/home/home-feed-list.tsx +++ b/apps/web/src/domains/home/home-feed-list.tsx @@ -36,15 +36,19 @@ export function HomeFeedList({ const visible = items.filter((item) => item.status !== "dismissed"); const eligible = excludeHighUrgency(visible); const presentCategories = getPresentCategories(eligible); - const filtered = filterByCategory(eligible, activeFilter); + const effectiveFilter = + activeFilter && presentCategories.includes(activeFilter) + ? activeFilter + : null; + const filtered = filterByCategory(eligible, effectiveFilter); const sorted = sortFeedItems(filtered); const grouped = groupByTime(sorted); return ( -
+
@@ -53,7 +57,7 @@ export function HomeFeedList({ variant="body-medium-lighter" className="py-[var(--app-spacing-xl)] text-center text-[var(--content-disabled)]" > - {activeFilter + {effectiveFilter ? "No items match the selected filter." : "No items to show."} diff --git a/apps/web/src/domains/home/home-page.tsx b/apps/web/src/domains/home/home-page.tsx index 532edffb282..ad09693d705 100644 --- a/apps/web/src/domains/home/home-page.tsx +++ b/apps/web/src/domains/home/home-page.tsx @@ -150,7 +150,7 @@ export function HomePage({ return ( {isUnread && ( - + )} diff --git a/apps/web/src/domains/home/home-suggestion-pill-bar.tsx b/apps/web/src/domains/home/home-suggestion-pill-bar.tsx index 290a4707e21..519a14ce773 100644 --- a/apps/web/src/domains/home/home-suggestion-pill-bar.tsx +++ b/apps/web/src/domains/home/home-suggestion-pill-bar.tsx @@ -80,7 +80,7 @@ export function HomeSuggestionPillBar({ style={{ width: 26, height: 26 }} aria-hidden="true" > - + {suggestion.label} diff --git a/packages/design-library/src/components/resizable-panel.tsx b/packages/design-library/src/components/resizable-panel.tsx index dd115af9ca1..f0c094d2de9 100644 --- a/packages/design-library/src/components/resizable-panel.tsx +++ b/packages/design-library/src/components/resizable-panel.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useLayoutEffect, useRef, useState, type ComponentProps, @@ -16,6 +17,8 @@ export interface ResizablePanelProps extends Omit, "childr right: ReactNode; /** Initial width of the left pane in px (default 400). */ defaultLeftWidth?: number; + /** Initial width of the left pane as a percentage of the container (0–100). Resolved via useLayoutEffect on mount. */ + defaultLeftPercent?: number; /** Minimum left pane width in px (default 300). */ minLeftWidth?: number; /** Minimum right pane width in px (default 300). */ @@ -36,6 +39,7 @@ export function ResizablePanel({ left, right, defaultLeftWidth = 400, + defaultLeftPercent, minLeftWidth = 300, minRightWidth = 300, onWidthChange, @@ -125,6 +129,24 @@ export function ResizablePanel({ return () => window.removeEventListener("resize", onResize); }, [clamp]); + // Resolve percentage-based default on mount (before paint) when no stored + // preference exists. This prevents a visible flash from the pixel fallback. + useLayoutEffect(() => { + if (defaultLeftPercent == null) return; + if (storageKey && typeof window !== "undefined") { + try { + const stored = localStorage.getItem(storageKey); + if (stored != null) return; + } catch { + // localStorage access can throw — fall through to percentage resolution. + } + } + const container = containerRef.current; + if (!container) return; + const target = (container.offsetWidth * defaultLeftPercent) / 100; + setLeftWidth(clamp(target)); + }, [defaultLeftPercent, storageKey, clamp]); + return (
Date: Wed, 20 May 2026 19:04:26 +0000 Subject: [PATCH 2/4] fix(web): address review feedback on homepage UI tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings flagged in review, both fixed properly rather than faithful-port mirroring of platform — this repo doesn't carry forward upstream tech debt. 1. ResizablePanel (Codex P2): malformed localStorage values made the useState initializer fall back to defaultLeftWidth while the new useLayoutEffect skipped percent resolution, leaving a persistent unintended width. Extracted a readStoredWidth() helper that does shape + finiteness validation in one place and is used by both the initializer and the percent-resolution effect. 2. HomeFeedList (Devin): the previous diff resolved a stale activeFilter via the derived effectiveFilter for rendering, but never reset the underlying state. If the selected category disappeared and later reappeared, the filter would silently re-activate. Use React's adjust-state-during-render pattern to keep activeFilter in sync with effectiveFilter — React bails out on same-value setState so this avoids a synchronization Effect. https://react.dev/reference/react/useState#storing-information-from-previous-renders https://claude.ai/code/session_013dBXRbLF218UhdLq7FEAvv --- apps/web/src/domains/home/home-feed-list.tsx | 12 ++++ .../src/components/resizable-panel.tsx | 55 ++++++++++--------- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/apps/web/src/domains/home/home-feed-list.tsx b/apps/web/src/domains/home/home-feed-list.tsx index 55b2bef57df..c9088026dfa 100644 --- a/apps/web/src/domains/home/home-feed-list.tsx +++ b/apps/web/src/domains/home/home-feed-list.tsx @@ -40,6 +40,18 @@ export function HomeFeedList({ activeFilter && presentCategories.includes(activeFilter) ? activeFilter : null; + + // Reset stale activeFilter during render when its category disappears + // from the feed. Without this, the previously-selected filter would + // silently re-activate if the category later reappears (e.g. a new + // notification of that category arrives). React bails out when the + // next state equals the current, so this is safe and preferable to a + // synchronization Effect. + // https://react.dev/reference/react/useState#storing-information-from-previous-renders + if (activeFilter !== effectiveFilter) { + setActiveFilter(effectiveFilter); + } + const filtered = filterByCategory(eligible, effectiveFilter); const sorted = sortFeedItems(filtered); const grouped = groupByTime(sorted); diff --git a/packages/design-library/src/components/resizable-panel.tsx b/packages/design-library/src/components/resizable-panel.tsx index f0c094d2de9..9c233d27cd8 100644 --- a/packages/design-library/src/components/resizable-panel.tsx +++ b/packages/design-library/src/components/resizable-panel.tsx @@ -10,6 +10,27 @@ import { import { cn } from "../utils/cn.js"; +/** + * Read a persisted pixel width from localStorage, validating both shape + * and finiteness. Returns `null` for unset/malformed entries or when + * storage access throws (strict-privacy contexts, quota errors, SSR). + */ +function readStoredWidth( + storageKey: string | undefined, + minLeftWidth: number, +): number | null { + if (!storageKey || typeof window === "undefined") return null; + try { + const stored = localStorage.getItem(storageKey); + if (stored == null) return null; + const parsed = Number(stored); + if (!Number.isFinite(parsed)) return null; + return Math.max(minLeftWidth, parsed); + } catch { + return null; + } +} + export interface ResizablePanelProps extends Omit, "children"> { /** Content for the left pane. */ left: ReactNode; @@ -49,20 +70,9 @@ export function ResizablePanel({ }: ResizablePanelProps) { const containerRef = useRef(null); - const [leftWidth, setLeftWidth] = useState(() => { - if (storageKey) { - try { - const stored = localStorage.getItem(storageKey); - if (stored != null) { - const parsed = Number(stored); - if (Number.isFinite(parsed)) return Math.max(minLeftWidth, parsed); - } - } catch { - // localStorage access can throw under strict-privacy contexts. - } - } - return defaultLeftWidth; - }); + const [leftWidth, setLeftWidth] = useState( + () => readStoredWidth(storageKey, minLeftWidth) ?? defaultLeftWidth, + ); const dragRef = useRef<{ startX: number; startWidth: number } | null>(null); const [isDragging, setIsDragging] = useState(false); @@ -129,23 +139,18 @@ export function ResizablePanel({ return () => window.removeEventListener("resize", onResize); }, [clamp]); - // Resolve percentage-based default on mount (before paint) when no stored - // preference exists. This prevents a visible flash from the pixel fallback. + // Resolve percentage-based default on mount (before paint) when no valid + // persisted preference exists. Runs in useLayoutEffect so the resolved + // width is committed before the browser paints, preventing a single-frame + // flash of the `defaultLeftWidth` pixel fallback. useLayoutEffect(() => { if (defaultLeftPercent == null) return; - if (storageKey && typeof window !== "undefined") { - try { - const stored = localStorage.getItem(storageKey); - if (stored != null) return; - } catch { - // localStorage access can throw — fall through to percentage resolution. - } - } + if (readStoredWidth(storageKey, minLeftWidth) !== null) return; const container = containerRef.current; if (!container) return; const target = (container.offsetWidth * defaultLeftPercent) / 100; setLeftWidth(clamp(target)); - }, [defaultLeftPercent, storageKey, clamp]); + }, [defaultLeftPercent, storageKey, minLeftWidth, clamp]); return (
Date: Wed, 20 May 2026 19:05:18 +0000 Subject: [PATCH 3/4] docs(web): document "don't carry upstream tech debt" migration policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add explicit guidance to apps/web/AGENTS.md so this rule persists across agent sessions and to anyone (human or AI) working on the migration: - A faithful port preserves behavior and feature parity, not every implementation choice. The stack changed; the code should change with it. - This is an OSS repo — align with React, React Router, and major OSS players' recommended patterns rather than mirroring the platform repo. - When a bot review flags a real issue in newly-ported code, fix it rather than dismissing as "matches upstream." The platform will be deprecated; we don't need to mirror its bugs. - Small refactors belong in the port PR; large ones get a separate Linear issue. - PR descriptions should call out divergences from the platform implementation so reviewers understand the deltas. https://claude.ai/code/session_013dBXRbLF218UhdLq7FEAvv --- apps/web/AGENTS.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 02e087f4ba2..0d8d7e25062 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -47,3 +47,17 @@ This app is being migrated from [`vellum-assistant-platform/web/`](https://githu - **Faithful copy, not simplification.** Port real implementations, not stubs. All Capacitor/native code paths must be preserved. - **Convention compliance on arrival.** Apply this repo's naming (kebab-case), import conventions (`.js` extensions, `@/` aliases), and directory structure as code is ported. - **No marketing or admin pages.** Only the assistant web app and auth/identity pages are migrating. + +### Do not bring over upstream tech debt + +A faithful port is about preserving **behavior and feature parity**, not preserving every implementation choice. The platform repo was Next.js + Server Components + a different state model. This repo is Vite + React Router v7 + Zustand + TanStack Query. The stack changed; the code should change with it. + +This is an **open-source repo**. We're publicly setting an example for how to build a React app well — convention, style guide, and patterns should align with what [React](https://react.dev/), [React Router](https://reactrouter.com/), and major OSS players recommend, not with whatever the platform repo happened to do. + +When porting code or reviewing drift PRs: + +- **Apply React-idiomatic patterns**, not platform-idiomatic ones. Examples: prefer [adjust-state-during-render](https://react.dev/reference/react/useState#storing-information-from-previous-renders) over `useEffect` for state synchronization; prefer [`key` resets](https://react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes) over manual cleanup effects; follow [React 19 patterns](https://react.dev/blog/2024/12/05/react-19) (Context as provider, ref as prop, `use()` for promises) — see also [`CLAUDE.md` — React conventions](../../CLAUDE.md). +- **If a bot review (Codex, Devin, vex-assistant-bot, etc.) flags a real issue in code you just ported, fix it.** Don't dismiss findings as "matches upstream" — that's exactly the tech debt this rule exists to stop. The upstream platform repo will be deprecated; we do not need to mirror its bugs. +- **If a refactor is called for, do it or ticket it.** Small refactors (extract a helper, replace `useEffect` with derived state, rename for clarity) belong in the port PR. Large refactors (rewrite a hook architecture, change a state management approach) get a separate Linear issue tracked in the [Web App Repo Move project](https://linear.app/vellum/project/web-app-repo-move-platform-vellum-assistant-1b8cd4f8-49cf-4b7b-b8e9-98b92046d2c3). +- **If something is just completely wrong, fix it.** Same PR if small and obviously correct, separate PR + Linear issue if it warrants discussion. +- **PR descriptions should call out divergences from the platform implementation** so reviewers understand what changed and why. A drift port that mirrors platform exactly is unusual; we expect deltas because the stack and conventions are different. From 32143390465c9f51eb2b6a4129945e7f8b91c4d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 19:08:09 +0000 Subject: [PATCH 4/4] docs(web): revert migration-policy section from AGENTS.md OSS contributors working in this repo have no context on the platform repo and shouldn't need to. The "don't carry over upstream tech debt" policy belongs in the platform repo's web docs (alongside the existing migration content), not here. Reverting the section added in 845ebf0. Equivalent guidance moves to vellum-assistant-platform/web/CLAUDE.md. https://claude.ai/code/session_013dBXRbLF218UhdLq7FEAvv --- apps/web/AGENTS.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 0d8d7e25062..02e087f4ba2 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -47,17 +47,3 @@ This app is being migrated from [`vellum-assistant-platform/web/`](https://githu - **Faithful copy, not simplification.** Port real implementations, not stubs. All Capacitor/native code paths must be preserved. - **Convention compliance on arrival.** Apply this repo's naming (kebab-case), import conventions (`.js` extensions, `@/` aliases), and directory structure as code is ported. - **No marketing or admin pages.** Only the assistant web app and auth/identity pages are migrating. - -### Do not bring over upstream tech debt - -A faithful port is about preserving **behavior and feature parity**, not preserving every implementation choice. The platform repo was Next.js + Server Components + a different state model. This repo is Vite + React Router v7 + Zustand + TanStack Query. The stack changed; the code should change with it. - -This is an **open-source repo**. We're publicly setting an example for how to build a React app well — convention, style guide, and patterns should align with what [React](https://react.dev/), [React Router](https://reactrouter.com/), and major OSS players recommend, not with whatever the platform repo happened to do. - -When porting code or reviewing drift PRs: - -- **Apply React-idiomatic patterns**, not platform-idiomatic ones. Examples: prefer [adjust-state-during-render](https://react.dev/reference/react/useState#storing-information-from-previous-renders) over `useEffect` for state synchronization; prefer [`key` resets](https://react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes) over manual cleanup effects; follow [React 19 patterns](https://react.dev/blog/2024/12/05/react-19) (Context as provider, ref as prop, `use()` for promises) — see also [`CLAUDE.md` — React conventions](../../CLAUDE.md). -- **If a bot review (Codex, Devin, vex-assistant-bot, etc.) flags a real issue in code you just ported, fix it.** Don't dismiss findings as "matches upstream" — that's exactly the tech debt this rule exists to stop. The upstream platform repo will be deprecated; we do not need to mirror its bugs. -- **If a refactor is called for, do it or ticket it.** Small refactors (extract a helper, replace `useEffect` with derived state, rename for clarity) belong in the port PR. Large refactors (rewrite a hook architecture, change a state management approach) get a separate Linear issue tracked in the [Web App Repo Move project](https://linear.app/vellum/project/web-app-repo-move-platform-vellum-assistant-1b8cd4f8-49cf-4b7b-b8e9-98b92046d2c3). -- **If something is just completely wrong, fix it.** Same PR if small and obviously correct, separate PR + Linear issue if it warrants discussion. -- **PR descriptions should call out divergences from the platform implementation** so reviewers understand what changed and why. A drift port that mirrors platform exactly is unusual; we expect deltas because the stack and conventions are different.