Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 65 additions & 21 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ function AppShell() {
const setSelectedLabelIds = (ids: string[]) =>
setLabelFilter({ projectId: selectedProjectId, ids });
// URL-derived project key (slug or id), captured once on mount and resolved
// against the project list as soon as it loads. Cleared after resolution.
// against the project list as soon as it loads. Release links use
// /projects/:key/releases/:release and apply the board release filter.
const [urlProjectKey, setUrlProjectKey] = useState<string | null>(() => {
const m = window.location.pathname.match(/^\/projects\/([^/]+)$/);
const m = window.location.pathname.match(/^\/projects\/([^/]+)(?:\/releases\/([^/]+))?$/);
if (!m) return null;
try {
return decodeURIComponent(m[1]);
Expand All @@ -69,6 +70,15 @@ function AppShell() {
return m[1];
}
});
const [urlReleaseTarget, setUrlReleaseTarget] = useState<string | null>(() => {
const m = window.location.pathname.match(/^\/projects\/([^/]+)\/releases\/([^/]+)$/);
if (!m) return null;
try {
return decodeURIComponent(m[2]);
} catch {
return m[2];
}
});
const [settingsProjectId, setSettingsProjectId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [syncing, setSyncing] = useState(false);
Expand All @@ -82,6 +92,7 @@ function AppShell() {
const [viewPreset, setViewPreset] = usePersistedState<BoardViewPreset>('baton.board.viewPreset', 'all');
const [releaseTarget, setReleaseTarget] = usePersistedState<string>('baton.board.releaseTarget', '');
const [paletteOpen, setPaletteOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [registryOpen, setRegistryOpen] = useState(false);
const [viewsOpen, setViewsOpen] = useState(false);
Expand Down Expand Up @@ -122,6 +133,11 @@ function AppShell() {
setUrlProjectKey(null);
if (found) {
setSelectedProjectId(found.id);
if (urlReleaseTarget !== null) {
setReleaseTarget(urlReleaseTarget);
setBoardGrouping('release');
Comment on lines +137 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

setUrlReleaseTarget(null);
}
return;
}
}
Expand All @@ -130,9 +146,9 @@ function AppShell() {
if (persistedStillExists) return;
const first = projects.find(p => p.status === 'active') ?? projects[0];
setSelectedProjectId(first ? first.id : null);
}, [projects, selectedProjectId, setSelectedProjectId, urlProjectKey]);
}, [projects, selectedProjectId, setSelectedProjectId, setReleaseTarget, urlProjectKey, urlReleaseTarget]);

// Keep the URL in sync with the selected project, except while a /tasks,
// Keep the URL in sync with the selected project/release, except while a /tasks,
// /users, or /agents deep link is active or still loading. Use replaceState
// so the back button still goes back to wherever the user came from.
React.useEffect(() => {
Expand All @@ -143,11 +159,15 @@ function AppShell() {
if (/^\/(tasks|users|agents)\//.test(window.location.pathname)) return;
const project = projects.find(p => p.id === selectedProjectId);
if (!project) return;
const href = `/projects/${project.slug || project.id}`;
const projectHref = `/projects/${project.slug || project.id}`;
const release = releaseTarget.trim();
const href = release
? `${projectHref}/releases/${encodeURIComponent(release)}`
: projectHref;
if (window.location.pathname !== href) {
window.history.replaceState(null, '', href);
}
}, [selectedProjectId, projects, linkedTask, linkedResource, urlProjectKey]);
}, [selectedProjectId, projects, linkedTask, linkedResource, urlProjectKey, releaseTarget]);

// Global Cmd+K / Ctrl+K to open the command palette.
React.useEffect(() => {
Expand Down Expand Up @@ -232,6 +252,7 @@ function AppShell() {
onOpenRegistry={() => setRegistryOpen(true)}
onOpenViews={() => setViewsOpen(true)}
onOpenPalette={() => setPaletteOpen(true)}
onOpenSidebar={() => setMobileSidebarOpen(true)}
density={density}
onToggleDensity={() => setDensity(d => d === 'comfortable' ? 'compact' : 'comfortable')}
rootOnly={rootOnly}
Expand All @@ -245,21 +266,44 @@ function AppShell() {
/>
<div className="flex flex-1 overflow-hidden">
{sidebarOpen && (
<Sidebar
projects={visibleProjects}
archivedCount={projects.length - projects.filter(p => p.status === 'active').length}
showArchived={showArchived}
onToggleArchived={setShowArchived}
selectedId={selectedProjectId}
onSelect={setSelectedProjectId}
selectedLabelIds={selectedLabelIds}
onLabelIdsChange={setSelectedLabelIds}
onCreate={async (data) => { await createProject(data); }}
onDelete={deleteProject}
onOpenSettings={setSettingsProjectId}
width={sidebarWidth}
onResize={setSidebarWidth}
/>
<div className="hidden md:flex flex-shrink-0">
<Sidebar
projects={visibleProjects}
archivedCount={projects.length - projects.filter(p => p.status === 'active').length}
showArchived={showArchived}
onToggleArchived={setShowArchived}
selectedId={selectedProjectId}
onSelect={setSelectedProjectId}
selectedLabelIds={selectedLabelIds}
onLabelIdsChange={setSelectedLabelIds}
onCreate={async (data) => { await createProject(data); }}
onDelete={deleteProject}
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
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>
Comment on lines +288 to +306

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
<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.

)}
<main className="flex-1 overflow-hidden flex flex-col">
<KanbanBoard
Expand Down
69 changes: 51 additions & 18 deletions frontend/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface Props {
onOpenRegistry: () => void;
onOpenViews: () => void;
onOpenPalette: () => void;
onOpenSidebar: () => void;
density: 'comfortable' | 'compact';
onToggleDensity: () => void;
rootOnly: boolean;
Expand All @@ -40,6 +41,7 @@ export function Header({
onOpenRegistry,
onOpenViews,
onOpenPalette,
onOpenSidebar,
density,
onToggleDensity,
rootOnly,
Expand All @@ -65,6 +67,9 @@ export function Header({
const projectHref = selectedProject
? `/projects/${selectedProject.slug || selectedProject.id}`
: null;
const releaseHref = projectHref && releaseTarget.trim()
? `${projectHref}/releases/${encodeURIComponent(releaseTarget.trim())}`
: null;

const copyProjectLink = async () => {
if (!projectHref) return;
Expand All @@ -76,6 +81,16 @@ export function Header({
}
};

const copyReleaseLink = async () => {
if (!releaseHref) return;
try {
await navigator.clipboard.writeText(`${window.location.origin}${releaseHref}`);
showToast('Release link copied', 'success');
} catch {
showToast('Copy failed', 'error');
}
};

useEffect(() => {
if (!accountOpen) return;
const handler = (e: MouseEvent) => {
Expand All @@ -99,15 +114,25 @@ export function Header({
}, [pickerOpen]);

return (
<header className="h-12 border-b border-[#1a1a1a] flex items-center gap-3 px-4 bg-[#080808] flex-shrink-0">
<div className="flex items-center gap-2 mr-2">
<header className="min-h-12 md:h-12 border-b border-[#1a1a1a] flex flex-wrap md:flex-nowrap items-center gap-2 md:gap-3 px-3 md:px-4 py-2 md:py-0 bg-[#080808] flex-shrink-0">
<button
type="button"
onClick={onOpenSidebar}
aria-label="Open projects"
title="Projects"
className="md:hidden text-zinc-500 hover:text-amber-400 text-lg px-1"
>
</button>

<div className="flex items-center gap-2 mr-1 md:mr-2">
<span className="text-amber-500 text-lg">🔥</span>
<span className="text-sm font-semibold text-zinc-200 tracking-tight">baton</span>
<span className="hidden sm:inline text-sm font-semibold text-zinc-200 tracking-tight">baton</span>
</div>

<div className="w-px h-4 bg-[#2a2a2a]" />
<div className="hidden md:block w-px h-4 bg-[#2a2a2a]" />

<div ref={pickerRef} className="relative flex items-center">
<div ref={pickerRef} className="relative flex items-center min-w-0 flex-1 md:flex-none">
{selectedProject && projectHref ? (
<a
href={projectHref}
Expand All @@ -119,7 +144,7 @@ export function Header({
// clicking just keeps the user on the same view.
}}
title={`Copy or open ${selectedProject.name}`}
className="text-sm text-zinc-300 hover:text-amber-400 transition-colors px-1 truncate max-w-[16rem]"
className="text-sm text-zinc-300 hover:text-amber-400 transition-colors px-1 truncate max-w-[11rem] sm:max-w-[16rem]"
>
{selectedProject.icon && <span className="mr-1">{selectedProject.icon}</span>}
{selectedProject.name}
Expand Down Expand Up @@ -172,7 +197,7 @@ export function Header({
)}
</div>

<div className="flex-1 max-w-xs ml-2">
<div className="order-2 md:order-none basis-full md:basis-auto flex-1 md:max-w-xs md:ml-2">
<input
value={search}
onChange={e => onSearch(e.target.value)}
Expand All @@ -191,13 +216,13 @@ export function Header({
<span aria-hidden="true">⌘</span>K
</button>

<div className="flex-1" />
<div className="hidden md:block flex-1" />

<button
onClick={onToggleRootOnly}
title={rootOnly ? 'Show all tasks' : 'Show root tasks with children nested'}
aria-pressed={rootOnly}
className={`text-xs px-2 py-1.5 rounded transition-colors ${
className={`hidden sm:inline-flex text-xs px-2 py-1.5 rounded transition-colors ${
rootOnly ? 'text-amber-400 bg-amber-500/10' : 'text-zinc-500 hover:text-amber-400 hover:bg-amber-500/5'
}`}
>
Expand All @@ -209,7 +234,7 @@ export function Header({
onChange={e => onGroupingChange(e.target.value as BoardGrouping)}
title="Board grouping"
aria-label="Board grouping"
className="bg-[#111] border border-[#1f1f1f] rounded text-xs text-zinc-400 px-2 py-1.5 outline-none hover:border-amber-500/30"
className="hidden md:block bg-[#111] border border-[#1f1f1f] rounded text-xs text-zinc-400 px-2 py-1.5 outline-none hover:border-amber-500/30"
>
<option value="priority">Priority</option>
<option value="tags">Tags</option>
Expand All @@ -221,7 +246,7 @@ export function Header({
onChange={e => onViewPresetChange(e.target.value as BoardViewPreset)}
title="Saved view"
aria-label="Saved view"
className="bg-[#111] border border-[#1f1f1f] rounded text-xs text-zinc-400 px-2 py-1.5 outline-none hover:border-amber-500/30"
className="hidden md:block bg-[#111] border border-[#1f1f1f] rounded text-xs text-zinc-400 px-2 py-1.5 outline-none hover:border-amber-500/30"
>
<option value="all">All</option>
<option value="open-high">Open high</option>
Expand All @@ -236,18 +261,26 @@ export function Header({
placeholder="release"
title="Filter by release label, e.g. 1.2 or v1.2"
aria-label="Release target"
className="w-24 bg-[#111] border border-[#1f1f1f] rounded text-xs text-zinc-300 placeholder-zinc-600 px-2 py-1.5 outline-none focus:border-amber-500/30"
className="w-20 sm:w-24 bg-[#111] border border-[#1f1f1f] rounded text-xs text-zinc-300 placeholder-zinc-600 px-2 py-1.5 outline-none focus:border-amber-500/30"
/>
<button
type="button"
onClick={copyReleaseLink}
disabled={!releaseHref}
title={releaseHref ? 'Copy release link' : 'Enter a release to copy a link'}
aria-label="Copy release link"
className="text-zinc-600 hover:text-amber-400 disabled:opacity-30 disabled:hover:text-zinc-600 px-1 text-xs"
>🔗</button>

<button
onClick={onOpenRegistry}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors"
className="hidden md:flex items-center gap-1.5 text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors"
>
Registry
</button>
<button
onClick={onOpenViews}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors"
className="hidden md:flex items-center gap-1.5 text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors"
>
Views
</button>
Expand All @@ -256,7 +289,7 @@ export function Header({
<button
onClick={onSync}
disabled={syncing}
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors disabled:opacity-40"
className="hidden md:flex items-center gap-1.5 text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors disabled:opacity-40"
>
<span className={syncing ? 'animate-spin' : ''}>⟳</span>
{syncing ? 'Syncing…' : 'Sync YAML'}
Expand All @@ -267,15 +300,15 @@ export function Header({
onClick={onToggleDensity}
title={density === 'comfortable' ? 'Switch to compact density' : 'Switch to comfortable density'}
aria-label="Toggle density"
className="text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors"
className="hidden md:block text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors"
>
{density === 'comfortable' ? '≡' : '☰'}
</button>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label="Toggle theme"
className="text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors"
className="hidden md:block text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors"
>
{theme === 'dark' ? '☼' : '☾'}
</button>
Expand All @@ -284,7 +317,7 @@ export function Header({
<div ref={accountRef} className="relative">
<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"
Comment on lines 318 to +320

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

>
{accountLabel}
</button>
Expand Down
Loading
Loading