feat(frontend): add release links and mobile board layout#69
Conversation
📝 WalkthroughWalkthroughThe frontend adds release-based project deep links and release-link copying, introduces mobile sidebar navigation, hides desktop-only header controls on small screens, and provides single-column mobile Kanban navigation. The previous mobile warning gate now renders nothing. ChangesFrontend routing and responsive layout
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Browser
participant App
participant Header
participant KanbanBoard
Browser->>App: Open project release URL
App->>App: Parse release target and set release grouping
App->>Header: Render selected release
Header->>Browser: Copy encoded release link
App->>KanbanBoard: Render board
KanbanBoard->>KanbanBoard: Select visible mobile column
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6d4304b0bc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| setReleaseTarget(urlReleaseTarget); | ||
| setBoardGrouping('release'); |
There was a problem hiding this comment.
Reset saved view when applying release links
Opening a copied /projects/:project/releases/:release URL only overwrites releaseTarget/grouping here; the persisted baton.board.viewPreset remains active. If the recipient previously selected Blocked, Open high, etc., the shared release link shows only that subset (and on mobile the preset selector is hidden), so copied release URLs are not reliably shareable unless the preset is reset or encoded in the URL.
Useful? React with 👍 / 👎.
| <button | ||
| onClick={() => setAccountOpen(o => !o)} | ||
| className="text-xs text-zinc-400 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors max-w-[10rem] truncate" | ||
| className="hidden md:block text-xs text-zinc-400 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors max-w-[10rem] truncate" |
There was a problem hiding this comment.
Expose the account menu on mobile
When auth is enabled (or claims are present), this hidden button is the only control that opens the account menu and reaches the logout() action below; the new hidden md:block class removes it under 768px and the mobile drawer does not provide an alternative. Authenticated phone users therefore have no in-app way to sign out or verify which account is active.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
frontend/src/App.tsx (1)
129-149: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winStale
urlReleaseTarget/releaseTargetcan survive URL resolution.
setUrlReleaseTarget(null)only fires inside theif (found)branch (Line 136-140). If the URL project key doesn't resolve to a project,urlProjectKeyis nulled buturlReleaseTargetis left set — harmless but permanently dead state. More impactful: when a user opens a plain/projects/:keylink (no release segment),urlReleaseTargetisnullso the persistedreleaseTargetfrom a prior session/project is never cleared, and the board can silently apply a stale release filter that isn't reflected in the URL the user actually navigated to.🐛 Suggested fix
if (found) { setSelectedProjectId(found.id); - if (urlReleaseTarget !== null) { - setReleaseTarget(urlReleaseTarget); - setBoardGrouping('release'); - setUrlReleaseTarget(null); - } + if (urlReleaseTarget !== null) { + setReleaseTarget(urlReleaseTarget); + setBoardGrouping('release'); + } else { + setReleaseTarget(''); + } + setUrlReleaseTarget(null); return; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/App.tsx` around lines 129 - 149, Update the project URL resolution effect to clear both URL-derived release state and the active release filter whenever a project URL is resolved, including when no release target is present; also clear urlReleaseTarget when the URL project key does not resolve. Preserve applying setReleaseTarget and release grouping only when a valid urlReleaseTarget is provided.frontend/src/components/Header.tsx (1)
316-323: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winSign-out becomes completely inaccessible on mobile.
The account trigger button (which exposes the "Sign out" action) is
hidden md:block, so on any viewport below themdbreakpoint there is no way to sign out at all — no equivalent control exists elsewhere (the hamburger only opens the project drawer). This is significant ifconfig.requireAuthis enabled and a user needs to end their session on a shared/borrowed mobile device.🐛 Suggested fix
<button onClick={() => setAccountOpen(o => !o)} - className="hidden md:block text-xs text-zinc-400 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors max-w-[10rem] truncate" + className="text-xs text-zinc-400 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors max-w-[6rem] md:max-w-[10rem] truncate" >🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/Header.tsx` around lines 316 - 323, Update the account trigger button in the account menu flow around accountRef and accountLabel so it remains accessible on mobile; remove or replace the md-only visibility restriction while preserving its existing click behavior and styling intent, ensuring users can still open the menu and reach “Sign out” below the md breakpoint.
🧹 Nitpick comments (5)
frontend/src/components/KanbanBoard.tsx (2)
496-508: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winMissing selected-state semantics on mobile status buttons.
Selection is conveyed only via color; add
aria-pressedso the current column is exposed to assistive tech, consistent with theRoottoggle pattern already used inHeader.tsx.♻️ Suggested addition
<button key={status} type="button" onClick={() => setMobileStatus(status)} + aria-pressed={mobileStatus === status} className={...}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/KanbanBoard.tsx` around lines 496 - 508, The mobile status buttons in KanbanBoard’s status selector lack selected-state semantics. Update the button rendered for each status to include aria-pressed based on whether mobileStatus equals status, matching the existing Root toggle pattern in Header.tsx while preserving the current styling and click behavior.
511-606: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winOff-screen mobile columns still fully render.
Non-selected columns are only CSS-hidden (
hiddenvsflex, Line 524); their bucket/priority grouping and task cards are still computed and mounted. On resource-constrained mobile devices with large boards, this means up to 3x the render work for content the user can't see.Consider skipping body rendering for non-selected columns on mobile, e.g. gate the column's inner content on
!isMobile || status === mobileStatus.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/components/KanbanBoard.tsx` around lines 511 - 606, Update the column rendering inside the COLUMNS.map loop to skip non-selected mobile column bodies: only compute/render bucketed task content, add forms, task cards, and empty-state content when !isMobile or status === mobileStatus. Keep the column header and resize behavior intact, and preserve all existing content for the selected mobile column and desktop columns.frontend/src/App.tsx (3)
59-81: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsolidate duplicate URL-parsing regexes.
urlProjectKeyandurlReleaseTargeteach re-match the pathname with near-identical, easy-to-desync regexes (one optionally captures the release, the other requires it). Parsing the pathname once and deriving both values from a single match would remove the duplication and the risk of the two patterns drifting apart.♻️ Suggested consolidation
+ const decodeSegment = (s: string) => { + try { return decodeURIComponent(s); } catch { return s; } + }; + const initialUrlMatch = React.useRef( + window.location.pathname.match(/^\/projects\/([^/]+)(?:\/releases\/([^/]+))?$/), + ).current; const [urlProjectKey, setUrlProjectKey] = useState<string | null>(() => - ... + initialUrlMatch ? decodeSegment(initialUrlMatch[1]) : null ); const [urlReleaseTarget, setUrlReleaseTarget] = useState<string | null>(() => - ... + initialUrlMatch?.[2] ? decodeSegment(initialUrlMatch[2]) : null );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/App.tsx` around lines 59 - 81, Consolidate the pathname parsing for urlProjectKey and urlReleaseTarget into a single match during their initial state setup, capturing the project and optional release segments together. Decode each captured segment independently with the existing malformed-encoding fallbacks, and derive both state values from that shared match instead of re-running separate regular expressions.
287-306: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winMobile drawer lacks dialog semantics / Escape-to-close.
The overlay works by tap-outside-to-close, but has no
role="dialog"/aria-modal, no focus trap, and no Escape key handler, so keyboard/screen-reader users have no way to dismiss it other than selecting a project.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/App.tsx` around lines 287 - 306, Update the mobile sidebar drawer rendered under mobileSidebarOpen to expose dialog semantics with role="dialog" and aria-modal, close when Escape is pressed, and trap focus within the drawer while open. Keep the existing backdrop click and project-selection close behavior unchanged, anchoring the implementation to the mobile overlay and its inner Sidebar wrapper.
268-307: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated
SidebarJSX between desktop and mobile.The desktop (269-286) and mobile (288-306) blocks render the same
Sidebarwith identical props aside fromonSelect,onOpenSettings,width, andonResize. Extracting the shared props into one object would remove ~15 lines of duplication and keep both variants in sync going forward.♻️ Suggested refactor
+ {(() => { + const sharedSidebarProps = { + projects: visibleProjects, + archivedCount: projects.length - projects.filter(p => p.status === 'active').length, + showArchived, + onToggleArchived: setShowArchived, + selectedId: selectedProjectId, + selectedLabelIds, + onLabelIdsChange: setSelectedLabelIds, + onCreate: async (data: Parameters<typeof createProject>[0]) => { await createProject(data); }, + onDelete: deleteProject, + }; + return ( + <> {sidebarOpen && ( <div className="hidden md:flex flex-shrink-0"> - <Sidebar ... /> + <Sidebar {...sharedSidebarProps} onSelect={setSelectedProjectId} onOpenSettings={setSettingsProjectId} width={sidebarWidth} onResize={setSidebarWidth} /> </div> )} {mobileSidebarOpen && ( <div className="md:hidden fixed inset-0 z-50 bg-black/60" onClick={() => setMobileSidebarOpen(false)}> <div className="h-full w-[88vw] max-w-[360px]" onClick={e => e.stopPropagation()}> - <Sidebar ... /> + <Sidebar {...sharedSidebarProps} onSelect={(id) => { setSelectedProjectId(id); setMobileSidebarOpen(false); }} onOpenSettings={(id) => { setSettingsProjectId(id); setMobileSidebarOpen(false); }} width={360} onResize={() => {}} /> </div> </div> )} + </> + ); + })()}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/src/App.tsx` around lines 268 - 307, Refactor the duplicated Sidebar usage in the desktop and mobile branches of the App component by extracting their identical props into a shared props object or equivalent reusable configuration. Keep the existing variant-specific onSelect, onOpenSettings, width, and onResize behavior for each branch while ensuring both Sidebar instances remain synchronized.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/App.tsx`:
- Around line 288-306: Replace the hardcoded width passed to Sidebar in the
mobile drawer with a value that fits the responsive wrapper, preserving the 88vw
width and 360px maximum constraint. Update the mobile Sidebar invocation and
keep the desktop sizing behavior unchanged.
In `@frontend/src/components/KanbanBoard.tsx`:
- Around line 494-510: Update the mobile column-switcher count in the COLUMNS
map to use visibleColumnTasks(status).length instead of byStatus(status).length,
keeping the displayed badge consistent with the filtered column content when
rootOnly is enabled.
---
Outside diff comments:
In `@frontend/src/App.tsx`:
- Around line 129-149: Update the project URL resolution effect to clear both
URL-derived release state and the active release filter whenever a project URL
is resolved, including when no release target is present; also clear
urlReleaseTarget when the URL project key does not resolve. Preserve applying
setReleaseTarget and release grouping only when a valid urlReleaseTarget is
provided.
In `@frontend/src/components/Header.tsx`:
- Around line 316-323: Update the account trigger button in the account menu
flow around accountRef and accountLabel so it remains accessible on mobile;
remove or replace the md-only visibility restriction while preserving its
existing click behavior and styling intent, ensuring users can still open the
menu and reach “Sign out” below the md breakpoint.
---
Nitpick comments:
In `@frontend/src/App.tsx`:
- Around line 59-81: Consolidate the pathname parsing for urlProjectKey and
urlReleaseTarget into a single match during their initial state setup, capturing
the project and optional release segments together. Decode each captured segment
independently with the existing malformed-encoding fallbacks, and derive both
state values from that shared match instead of re-running separate regular
expressions.
- Around line 287-306: Update the mobile sidebar drawer rendered under
mobileSidebarOpen to expose dialog semantics with role="dialog" and aria-modal,
close when Escape is pressed, and trap focus within the drawer while open. Keep
the existing backdrop click and project-selection close behavior unchanged,
anchoring the implementation to the mobile overlay and its inner Sidebar
wrapper.
- Around line 268-307: Refactor the duplicated Sidebar usage in the desktop and
mobile branches of the App component by extracting their identical props into a
shared props object or equivalent reusable configuration. Keep the existing
variant-specific onSelect, onOpenSettings, width, and onResize behavior for each
branch while ensuring both Sidebar instances remain synchronized.
In `@frontend/src/components/KanbanBoard.tsx`:
- Around line 496-508: The mobile status buttons in KanbanBoard’s status
selector lack selected-state semantics. Update the button rendered for each
status to include aria-pressed based on whether mobileStatus equals status,
matching the existing Root toggle pattern in Header.tsx while preserving the
current styling and click behavior.
- Around line 511-606: Update the column rendering inside the COLUMNS.map loop
to skip non-selected mobile column bodies: only compute/render bucketed task
content, add forms, task cards, and empty-state content when !isMobile or status
=== mobileStatus. Keep the column header and resize behavior intact, and
preserve all existing content for the selected mobile column and desktop
columns.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a71cccdf-1928-4e1b-a55d-46bc577cf093
📒 Files selected for processing (4)
frontend/src/App.tsxfrontend/src/components/Header.tsxfrontend/src/components/KanbanBoard.tsxfrontend/src/components/MobileGate.tsx
| <div className="md:hidden fixed inset-0 z-50 bg-black/60" onClick={() => setMobileSidebarOpen(false)}> | ||
| <div className="h-full w-[88vw] max-w-[360px]" onClick={e => e.stopPropagation()}> | ||
| <Sidebar | ||
| projects={visibleProjects} | ||
| archivedCount={projects.length - projects.filter(p => p.status === 'active').length} | ||
| showArchived={showArchived} | ||
| onToggleArchived={setShowArchived} | ||
| selectedId={selectedProjectId} | ||
| onSelect={(id) => { setSelectedProjectId(id); setMobileSidebarOpen(false); }} | ||
| selectedLabelIds={selectedLabelIds} | ||
| onLabelIdsChange={setSelectedLabelIds} | ||
| onCreate={async (data) => { await createProject(data); }} | ||
| onDelete={deleteProject} | ||
| onOpenSettings={(id) => { setSettingsProjectId(id); setMobileSidebarOpen(false); }} | ||
| width={360} | ||
| onResize={() => {}} | ||
| /> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Fixed width={360} can exceed the 88vw drawer container on narrow screens.
The drawer wrapper is w-[88vw] max-w-[360px], but Sidebar receives a hardcoded width={360} which it applies via inline style={{ width }}. At the PR's own smoke-test viewport (390px), 88vw = 343.2px < 360px, so the aside renders wider than its wrapper and visually overflows the intended drawer bounds by ~17px.
🐛 Suggested fix
- <div className="h-full w-[88vw] max-w-[360px]" onClick={e => e.stopPropagation()}>
+ <div className="h-full w-[88vw] max-w-[360px] overflow-hidden" onClick={e => e.stopPropagation()}>
<Sidebar
...
- width={360}
+ width={Math.min(360, Math.round(window.innerWidth * 0.88))}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="md:hidden fixed inset-0 z-50 bg-black/60" onClick={() => setMobileSidebarOpen(false)}> | |
| <div className="h-full w-[88vw] max-w-[360px]" onClick={e => e.stopPropagation()}> | |
| <Sidebar | |
| projects={visibleProjects} | |
| archivedCount={projects.length - projects.filter(p => p.status === 'active').length} | |
| showArchived={showArchived} | |
| onToggleArchived={setShowArchived} | |
| selectedId={selectedProjectId} | |
| onSelect={(id) => { setSelectedProjectId(id); setMobileSidebarOpen(false); }} | |
| selectedLabelIds={selectedLabelIds} | |
| onLabelIdsChange={setSelectedLabelIds} | |
| onCreate={async (data) => { await createProject(data); }} | |
| onDelete={deleteProject} | |
| onOpenSettings={(id) => { setSettingsProjectId(id); setMobileSidebarOpen(false); }} | |
| width={360} | |
| onResize={() => {}} | |
| /> | |
| </div> | |
| </div> | |
| <div className="md:hidden fixed inset-0 z-50 bg-black/60" onClick={() => setMobileSidebarOpen(false)}> | |
| <div className="h-full w-[88vw] max-w-[360px] overflow-hidden" onClick={e => e.stopPropagation()}> | |
| <Sidebar | |
| projects={visibleProjects} | |
| archivedCount={projects.length - projects.filter(p => p.status === 'active').length} | |
| showArchived={showArchived} | |
| onToggleArchived={setShowArchived} | |
| selectedId={selectedProjectId} | |
| onSelect={(id) => { setSelectedProjectId(id); setMobileSidebarOpen(false); }} | |
| selectedLabelIds={selectedLabelIds} | |
| onLabelIdsChange={setSelectedLabelIds} | |
| onCreate={async (data) => { await createProject(data); }} | |
| onDelete={deleteProject} | |
| onOpenSettings={(id) => { setSettingsProjectId(id); setMobileSidebarOpen(false); }} | |
| width={Math.min(360, Math.round(window.innerWidth * 0.88))} | |
| onResize={() => {}} | |
| /> | |
| </div> | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/App.tsx` around lines 288 - 306, Replace the hardcoded width
passed to Sidebar in the mobile drawer with a value that fits the responsive
wrapper, preserving the 88vw width and 360px maximum constraint. Update the
mobile Sidebar invocation and keep the desktop sizing behavior unchanged.
| <div className="md:hidden flex gap-1 border-b border-[#1a1a1a] px-2 py-2 overflow-x-auto"> | ||
| {COLUMNS.map(({ status, label }) => ( | ||
| <button | ||
| key={status} | ||
| type="button" | ||
| onClick={() => setMobileStatus(status)} | ||
| className={`flex-1 min-w-24 rounded-md border px-3 py-2 text-xs transition-colors ${ | ||
| mobileStatus === status | ||
| ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' | ||
| : 'border-[#1f1f1f] bg-[#0a0a0a] text-zinc-500' | ||
| }`} | ||
| > | ||
| {label} | ||
| <span className="ml-1 text-[10px] text-zinc-600">{byStatus(status).length}</span> | ||
| </button> | ||
| ))} | ||
| </div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Mobile column-switcher counts ignore rootOnly filtering.
The pill counts use byStatus(status).length (Line 507), the raw unfiltered task count, while the actual rendered column content uses visibleColumnTasks(status) (Line 516), which excludes children when rootOnly is true (the default). On mobile, the badge will over-count relative to what's actually shown in that column for any project with subtasks.
🐛 Suggested fix
- <span className="ml-1 text-[10px] text-zinc-600">{byStatus(status).length}</span>
+ <span className="ml-1 text-[10px] text-zinc-600">{visibleColumnTasks(status).length}</span>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="md:hidden flex gap-1 border-b border-[#1a1a1a] px-2 py-2 overflow-x-auto"> | |
| {COLUMNS.map(({ status, label }) => ( | |
| <button | |
| key={status} | |
| type="button" | |
| onClick={() => setMobileStatus(status)} | |
| className={`flex-1 min-w-24 rounded-md border px-3 py-2 text-xs transition-colors ${ | |
| mobileStatus === status | |
| ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' | |
| : 'border-[#1f1f1f] bg-[#0a0a0a] text-zinc-500' | |
| }`} | |
| > | |
| {label} | |
| <span className="ml-1 text-[10px] text-zinc-600">{byStatus(status).length}</span> | |
| </button> | |
| ))} | |
| </div> | |
| <div className="md:hidden flex gap-1 border-b border-[`#1a1a1a`] px-2 py-2 overflow-x-auto"> | |
| {COLUMNS.map(({ status, label }) => ( | |
| <button | |
| key={status} | |
| type="button" | |
| onClick={() => setMobileStatus(status)} | |
| className={`flex-1 min-w-24 rounded-md border px-3 py-2 text-xs transition-colors ${ | |
| mobileStatus === status | |
| ? 'border-amber-500/30 bg-amber-500/10 text-amber-400' | |
| : 'border-[`#1f1f1f`] bg-[`#0a0a0a`] text-zinc-500' | |
| }`} | |
| > | |
| {label} | |
| <span className="ml-1 text-[10px] text-zinc-600">{visibleColumnTasks(status).length}</span> | |
| </button> | |
| ))} | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/KanbanBoard.tsx` around lines 494 - 510, Update the
mobile column-switcher count in the COLUMNS map to use
visibleColumnTasks(status).length instead of byStatus(status).length, keeping
the displayed badge consistent with the filtered column content when rootOnly is
enabled.
Summary
Validation
Summary by CodeRabbit